diff --git a/.github/workflows/dockerhub-branch-images.yml b/.github/workflows/dockerhub-branch-images.yml index 49850cf..d814905 100644 --- a/.github/workflows/dockerhub-branch-images.yml +++ b/.github/workflows/dockerhub-branch-images.yml @@ -20,13 +20,20 @@ jobs: include: - name: korokd image: agentland-korokd + context: . dockerfile: docker/Dockerfile.korokd - name: gateway image: agentland-gateway + context: . dockerfile: docker/Dockerfile.gateway - name: agentcore image: agentland-agentcore + context: . dockerfile: docker/Dockerfile.agentcore + - name: agent + image: agentland-agent + context: app/agentland-agent + dockerfile: app/agentland-agent/Dockerfile steps: - uses: actions/checkout@v4 @@ -40,7 +47,7 @@ jobs: - uses: docker/build-push-action@v6 with: - context: . + context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} push: true tags: ${{ env.DOCKERHUB_NAMESPACE }}/${{ matrix.image }}:latest diff --git a/Makefile b/Makefile index 702fc1a..ecd0fa0 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ help: ## Display this help. .PHONY: manifests manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. - $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases + $(CONTROLLER_GEN) rbac:roleName=manager-role crd:maxDescLen=0 webhook paths="./..." output:crd:artifacts:config=config/crd/bases .PHONY: sync-chart-crds sync-chart-crds: manifests ## Sync generated CRDs into the Helm chart. diff --git a/api/v1alpha1/sandbox_types.go b/api/v1alpha1/sandbox_types.go index 4f0408c..ca1eab3 100644 --- a/api/v1alpha1/sandbox_types.go +++ b/api/v1alpha1/sandbox_types.go @@ -17,21 +17,30 @@ limitations under the License. package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // SandboxTemplate defines a generic pod startup template for all sandbox types. type SandboxTemplate struct { - // +kubebuilder:validation:Required - Image string `json:"image"` - // RuntimeClassName controls which container runtime to use for the sandbox Pod - // Typical values include "kata-qemu" or "gvisor" depending on cluster RuntimeClass setup + // Image is the legacy single-container shortcut. It is ignored when PodSpec is set. + // +optional + Image string `json:"image,omitempty"` + // RuntimeClassName controls which container runtime to use for the sandbox Pod. + // It is ignored when PodSpec.runtimeClassName is set. + // Typical values include "kata-qemu" or "gvisor" depending on cluster RuntimeClass setup. // +optional RuntimeClassName string `json:"runtimeClassName,omitempty"` + // Command is the legacy single-container shortcut. It is ignored when PodSpec is set. // +optional Command []string `json:"command,omitempty"` + // Args is the legacy single-container shortcut. It is ignored when PodSpec is set. // +optional Args []string `json:"args,omitempty"` + // PodSpec is the official Kubernetes PodSpec used to create sandbox Pods. + // Agentland still injects its reserved JWT and workspace volumes into the resulting Pod. + // +optional + PodSpec *corev1.PodSpec `json:"podSpec,omitempty"` } // SandboxSpec defines the desired state of Sandbox. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index cc68fc4..5318c98 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,6 +21,7 @@ limitations under the License. package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -737,6 +738,11 @@ func (in *SandboxTemplate) DeepCopyInto(out *SandboxTemplate) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.PodSpec != nil { + in, out := &in.PodSpec, &out.PodSpec + *out = new(corev1.PodSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SandboxTemplate. diff --git a/app/agentland-agent/.gitignore b/app/agentland-agent/.gitignore new file mode 100644 index 0000000..ac187a1 --- /dev/null +++ b/app/agentland-agent/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +scripts/ +.venv/ \ No newline at end of file diff --git a/app/agentland-agent/Dockerfile b/app/agentland-agent/Dockerfile new file mode 100644 index 0000000..c770d9b --- /dev/null +++ b/app/agentland-agent/Dockerfile @@ -0,0 +1,45 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + NPM_CONFIG_REGISTRY=https://registry.npmmirror.com \ + VIRTUAL_ENV=/opt/venv \ + PATH=/opt/venv/bin:$PATH + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gnupg \ + software-properties-common \ + && install -d -m 0755 /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && add-apt-repository -y ppa:deadsnakes/ppa \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + nodejs \ + python3.12 \ + python3.12-venv \ + && python3.12 -m venv /opt/venv \ + && pip install --upgrade pip setuptools wheel \ + && npm config set registry "${NPM_CONFIG_REGISTRY}" \ + && apt-get purge -y --auto-remove software-properties-common \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . + +RUN pip install -r requirements.txt + +COPY app ./app + +EXPOSE 8000 + +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/agentland-agent/README.md b/app/agentland-agent/README.md new file mode 100644 index 0000000..595e709 --- /dev/null +++ b/app/agentland-agent/README.md @@ -0,0 +1,128 @@ +# Agentland Agent FastAPI demo + +这个 demo 提供一个纯 HTTP 的 LangGraph coding agent 服务。它现在有两层 +能力: + +- 一个普通 agent 分支,用于持续对话 +- 一个 Ralph 分支,用于执行多轮任务编排 + +统一入口会先经过一个 LangGraph graph router。router agent 会输出结构化 +JSON,将请求分流到 chat 或 task 分支。 + +项目结构如下: + +- `app/main.py`:应用入口 +- `app/api/endpoints/`:HTTP 路由 +- `app/schemas/`:请求模型 +- `app/models/`:运行态模型 +- `app/services/`:核心逻辑,包括 agent loop、router、memory、Ralph +- `app/database/`:数据库占位目录 +- `tests/`:测试 + +## Start the service + +按下面的步骤启动服务: + +```bash +cd demo/agentland-agent +uv pip install -r requirements.txt +export OPENAI_API_KEY=your_key +uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +## API overview + +服务暴露以下接口: + +- `POST /v1/chat/stream`:统一 SSE chat 接口,内部先走 graph router +- `POST /v1/ralph/stream`:直接启动 Ralph 风格的 10 轮外层编排 +- `POST /v1/sessions/{session_id}/steer`:向活跃 chat 会话写入 steering + 消息 +- `POST /v1/sessions/{session_id}/followup`:向活跃 chat 会话写入 + follow-up 消息 +- `GET /health`:健康检查 + +## Unified chat interface + +`POST /v1/chat/stream` 是整合后的入口。它先调用一个 LangGraph router +node。router 会返回 JSON 结果: + +```json +{ + "intent": "chat", + "reason": "plain conversation", + "source": "model" +} +``` + +然后 graph 会按 `intent` 分支: + +- `chat`:走普通 agent 服务 +- `task`:走 Ralph loop 服务 + +返回仍然是 `text/event-stream`。这个接口会先发送一条 `route` 事件,然后 +继续发送目标分支自己的 SSE 事件。 + +请求体示例: + +```json +{ + "message": "请帮我实现一个需求,并修改工作区里的代码", + "workspace_path": "/absolute/path/to/workspace", + "session_id": "demo-session" +} +``` + +当它进入普通 chat 分支时,会先发送一条 `session` 事件,其中包含 +`session_file`。这个文件使用和 `pi-mono` 相同原理的 append-only JSONL +memory。 + +## Persistent memory + +普通 chat 分支不再依赖进程内 `history`。它使用一个持久化 session manager: + +- 会话目录按 `cwd` 分桶 +- 会话文件是 append-only JSONL +- 每条 entry 都有 `id` 和 `parentId` +- 当前上下文由 leaf 路径回放得到 +- 支持 `compaction`、`branch_summary`、`custom`、`custom_message` + +默认会话根目录是 `~/.pi/agent/sessions/`。测试时你也可以通过 +`PI_SESSION_ROOT` 覆盖它。 + +## Ralph interface + +`POST /v1/ralph/stream` 直接接收一个原始需求,并在现有 `run_agent` +之上启动 Ralph 风格的外层循环。 + +它和参考 `ralph` 保持同一原理: + +- 外层循环默认最多运行 10 轮 +- 每一轮都以全新的聊天上下文重新启动 agent +- 持久化状态落到工作区 `.ralph//prd.json` 和 + `.ralph//progress.txt` +- 只有当 agent 输出 `COMPLETE` 时才提前停止 + +请求体示例: + +```json +{ + "requirement": "为当前项目增加一个 Ralph 风格的任务编排接口", + "workspace_path": "/absolute/path/to/workspace", + "session_id": "ralph-demo" +} +``` + +除了常规的 `assistant_delta`、`tool_call`、`tool_result` 事件以外, +Ralph 还会发送这些生命周期事件: + +- `session` +- `planner_fallback` +- `plan_ready` +- `iteration_start` +- `iteration_complete` +- `done` + +为了降低 OpenAI 兼容网关差异带来的影响,chat router、普通 agent 和 +Ralph planner 都显式关闭了 `Responses API` 自动路由,优先使用更通用的 +chat completions 路径。 diff --git a/app/agentland-agent/app/__init__.py b/app/agentland-agent/app/__init__.py new file mode 100644 index 0000000..da54ab1 --- /dev/null +++ b/app/agentland-agent/app/__init__.py @@ -0,0 +1,2 @@ +"""Agentland Agent FastAPI 应用包。""" + diff --git a/app/agentland-agent/app/api/__init__.py b/app/agentland-agent/app/api/__init__.py new file mode 100644 index 0000000..36e87b9 --- /dev/null +++ b/app/agentland-agent/app/api/__init__.py @@ -0,0 +1,14 @@ +"""API 路由聚合。""" + +from fastapi import APIRouter + +from app.api.endpoints.chat import router as chat_router +from app.api.endpoints.health import router as health_router +from app.api.endpoints.ralph import router as ralph_router +from app.api.endpoints.sessions import router as sessions_router + +api_router = APIRouter() +api_router.include_router(health_router) +api_router.include_router(chat_router) +api_router.include_router(ralph_router) +api_router.include_router(sessions_router) diff --git a/app/agentland-agent/app/api/endpoints/__init__.py b/app/agentland-agent/app/api/endpoints/__init__.py new file mode 100644 index 0000000..b3497f4 --- /dev/null +++ b/app/agentland-agent/app/api/endpoints/__init__.py @@ -0,0 +1,2 @@ +"""API 端点模块。""" + diff --git a/app/agentland-agent/app/api/endpoints/chat.py b/app/agentland-agent/app/api/endpoints/chat.py new file mode 100644 index 0000000..47c82bb --- /dev/null +++ b/app/agentland-agent/app/api/endpoints/chat.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +"""聊天流式端点。""" + +from fastapi import APIRouter +from fastapi.responses import StreamingResponse + +from app.schemas.chat import ChatStreamRequest +from app.services.chat_service import stream_chat + +router = APIRouter() + + +@router.post("/v1/chat/stream") +async def chat_stream(request: ChatStreamRequest) -> StreamingResponse: + """启动一次会话并以 SSE 流式返回事件。""" + + return await stream_chat(request) + diff --git a/app/agentland-agent/app/api/endpoints/health.py b/app/agentland-agent/app/api/endpoints/health.py new file mode 100644 index 0000000..df69a8e --- /dev/null +++ b/app/agentland-agent/app/api/endpoints/health.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +"""健康检查端点。""" + +from fastapi import APIRouter + +router = APIRouter() + + +@router.get("/health") +async def health() -> dict[str, str]: + """返回服务健康状态。""" + + return {"status": "ok"} + diff --git a/app/agentland-agent/app/api/endpoints/ralph.py b/app/agentland-agent/app/api/endpoints/ralph.py new file mode 100644 index 0000000..4f3f292 --- /dev/null +++ b/app/agentland-agent/app/api/endpoints/ralph.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +"""Ralph-compatible stream endpoint.""" + +from fastapi import APIRouter +from fastapi.responses import StreamingResponse + +from app.schemas.ralph import RalphStreamRequest +from app.services.ralph_service import stream_ralph + +router = APIRouter() + + +@router.post("/v1/ralph/stream") +async def ralph_stream(request: RalphStreamRequest) -> StreamingResponse: + """Start or resume a Ralph-style orchestration loop.""" + + return await stream_ralph(request) diff --git a/app/agentland-agent/app/api/endpoints/sessions.py b/app/agentland-agent/app/api/endpoints/sessions.py new file mode 100644 index 0000000..e2fc77f --- /dev/null +++ b/app/agentland-agent/app/api/endpoints/sessions.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +"""会话控制端点(steer/follow-up)。""" + +from fastapi import APIRouter + +from app.schemas.chat import QueueMessageRequest +from app.services.chat_service import queue_followup, queue_steering + +router = APIRouter() + + +@router.post("/v1/sessions/{session_id}/steer") +async def steer(session_id: str, request: QueueMessageRequest) -> dict[str, object]: + """向会话的 steering 队列写入消息。""" + + return queue_steering(session_id=session_id, message=request.message) + + +@router.post("/v1/sessions/{session_id}/followup") +async def followup(session_id: str, request: QueueMessageRequest) -> dict[str, object]: + """向会话的 follow-up 队列写入消息。""" + + return queue_followup(session_id=session_id, message=request.message) + diff --git a/app/agentland-agent/app/database/__init__.py b/app/agentland-agent/app/database/__init__.py new file mode 100644 index 0000000..d39fe70 --- /dev/null +++ b/app/agentland-agent/app/database/__init__.py @@ -0,0 +1,2 @@ +"""数据库层占位目录(当前 demo 使用内存会话,无外部数据库)。""" + diff --git a/app/agentland-agent/app/main.py b/app/agentland-agent/app/main.py new file mode 100644 index 0000000..3e97ac2 --- /dev/null +++ b/app/agentland-agent/app/main.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +"""FastAPI 应用主入口。""" + +from fastapi import FastAPI + +from app.api import api_router + + +def create_app() -> FastAPI: + """创建并装配 FastAPI 应用。""" + + application = FastAPI(title="LangGraph Coding Agent SSE Service") + application.include_router(api_router) + return application + + +app = create_app() + diff --git a/app/agentland-agent/app/models/__init__.py b/app/agentland-agent/app/models/__init__.py new file mode 100644 index 0000000..b6c17da --- /dev/null +++ b/app/agentland-agent/app/models/__init__.py @@ -0,0 +1,2 @@ +"""领域模型。""" + diff --git a/app/agentland-agent/app/models/ralph.py b/app/agentland-agent/app/models/ralph.py new file mode 100644 index 0000000..b69bfb7 --- /dev/null +++ b/app/agentland-agent/app/models/ralph.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +"""Ralph orchestration domain models.""" + +from pathlib import Path +from threading import Lock + +from pydantic import BaseModel, Field + + +class RalphUserStory(BaseModel): + """A single Ralph user story item persisted in `prd.json`.""" + + id: str = Field(min_length=1) + title: str = Field(min_length=1) + description: str = Field(min_length=1) + acceptanceCriteria: list[str] = Field(min_length=1) + priority: int = Field(ge=1) + passes: bool = False + notes: str = "" + + +class RalphPrd(BaseModel): + """Structured Ralph plan stored on disk between iterations.""" + + project: str = Field(min_length=1) + branchName: str = Field(min_length=1) + description: str = Field(min_length=1) + userStories: list[RalphUserStory] = Field(min_length=1) + + def sorted_stories(self) -> list[RalphUserStory]: + """Return stories ordered by priority and id.""" + + return sorted(self.userStories, key=lambda story: (story.priority, story.id)) + + +class RalphRunState(BaseModel): + """In-memory metadata for a Ralph run.""" + + session_id: str + run_id: str + workspace_path: Path + session_root: Path + run_dir: Path + prd_path: Path + progress_path: Path + + model_config = {"arbitrary_types_allowed": True} + + +class RalphRunLock: + """Concurrency guard for a Ralph chat session.""" + + __slots__ = ("session_id", "lock", "running") + + def __init__(self, session_id: str) -> None: + self.session_id = session_id + self.lock = Lock() + self.running = False diff --git a/app/agentland-agent/app/models/session.py b/app/agentland-agent/app/models/session.py new file mode 100644 index 0000000..a399f79 --- /dev/null +++ b/app/agentland-agent/app/models/session.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +"""会话状态模型。""" + +import queue +import threading +from pathlib import Path +from typing import TYPE_CHECKING +from dataclasses import dataclass, field + +from langchain_core.messages import AnyMessage + +if TYPE_CHECKING: + from app.services.session_memory import SessionManager + + +@dataclass(slots=True) +class SessionState: + """活跃会话运行态及会话级消息队列。""" + + session_id: str + workspace_path: Path + manager: SessionManager + steering_queue: queue.Queue[AnyMessage] = field(default_factory=lambda: queue.Queue(maxsize=64)) + followup_queue: queue.Queue[AnyMessage] = field(default_factory=lambda: queue.Queue(maxsize=64)) + lock: threading.Lock = field(default_factory=threading.Lock) + running: bool = False diff --git a/app/agentland-agent/app/schemas/__init__.py b/app/agentland-agent/app/schemas/__init__.py new file mode 100644 index 0000000..54bb30d --- /dev/null +++ b/app/agentland-agent/app/schemas/__init__.py @@ -0,0 +1,2 @@ +"""Pydantic 请求/响应模型。""" + diff --git a/app/agentland-agent/app/schemas/chat.py b/app/agentland-agent/app/schemas/chat.py new file mode 100644 index 0000000..8a8753e --- /dev/null +++ b/app/agentland-agent/app/schemas/chat.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +"""聊天相关请求模型。""" + +import os +from typing import Literal + +from pydantic import BaseModel, Field + +DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. Use tools when needed." + + +class ChatStreamRequest(BaseModel): + """单次流式会话请求体。""" + + message: str = Field(min_length=1) + deep: bool = False + session_id: str | None = None + workspace_path: str | None = None + project_name: str | None = None + system: str = DEFAULT_SYSTEM_PROMPT + model: str = Field(default_factory=lambda: os.getenv("OPENAI_MODEL", "gpt-5.2-codex")) + base_url: str | None = Field(default_factory=lambda: os.getenv("OPENAI_BASE_URL")) + timeout: float = 60.0 + max_turns: int = 25 + agent_max_turns: int = 25 + iterations: int = 10 + queue_mode: Literal["one-at-a-time", "all"] = "one-at-a-time" + + +class QueueMessageRequest(BaseModel): + """steering/follow-up 入队请求体。""" + + message: str = Field(min_length=1) diff --git a/app/agentland-agent/app/schemas/ralph.py b/app/agentland-agent/app/schemas/ralph.py new file mode 100644 index 0000000..2ffaf32 --- /dev/null +++ b/app/agentland-agent/app/schemas/ralph.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +"""Ralph stream request schema.""" + +import os + +from pydantic import BaseModel, Field + + +class RalphStreamRequest(BaseModel): + """Request body for starting or resuming a Ralph-style run.""" + + requirement: str = Field(min_length=1) + workspace_path: str | None = None + session_id: str | None = None + project_name: str | None = None + model: str = Field(default_factory=lambda: os.getenv("OPENAI_MODEL", "gpt-5.2-codex")) + base_url: str | None = Field(default_factory=lambda: os.getenv("OPENAI_BASE_URL")) + timeout: float = 60.0 + agent_max_turns: int = 25 + iterations: int = 10 diff --git a/app/agentland-agent/app/services/__init__.py b/app/agentland-agent/app/services/__init__.py new file mode 100644 index 0000000..39754fa --- /dev/null +++ b/app/agentland-agent/app/services/__init__.py @@ -0,0 +1,2 @@ +"""业务服务层。""" + diff --git a/app/agentland-agent/app/services/agent_loop.py b/app/agentland-agent/app/services/agent_loop.py new file mode 100644 index 0000000..39a497b --- /dev/null +++ b/app/agentland-agent/app/services/agent_loop.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +"""基于 LangGraph 原语实现的核心 Agent 循环。 + +本模块对齐 Go demo 语义: +- LLM -> tools -> LLM 循环 +- steering 消息可在工具执行阶段打断 +- follow-up 消息会在原本停止后继续执行 +""" + +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from typing import Literal, TypedDict + +from langchain_core.messages import AIMessage, AnyMessage, ToolMessage +from langgraph.graph import END, START, StateGraph +from langgraph.prebuilt import ToolNode + + +@dataclass(slots=True) +class Hooks: + """可选生命周期回调,供 UI/传输层订阅。""" + + on_turn_start: Callable[[int], None] | None = None + on_assistant_delta: Callable[[str], None] | None = None + on_assistant: Callable[[AIMessage], None] | None = None + on_tool_call: Callable[[dict], None] | None = None + on_tool_result: Callable[[ToolMessage], None] | None = None + on_turn_end: Callable[[int], None] | None = None + + +@dataclass(slots=True) +class AgentConfig: + """Agent 循环配置。""" + + model: object + tools: Sequence[object] + max_turns: int = 25 + transform_context: Callable[[list[AnyMessage]], list[AnyMessage]] | None = None + get_steering_messages: Callable[[], list[AnyMessage]] | None = None + get_followup_messages: Callable[[], list[AnyMessage]] | None = None + hooks: Hooks = field(default_factory=Hooks) + + +class LoopState(TypedDict): + """一次图执行的状态结构。""" + + messages: list[AnyMessage] + turn_count: int + max_turns: int + has_tool_calls: bool + pending_steering: list[AnyMessage] + active_turn: int + + +def run_agent(messages: list[AnyMessage], cfg: AgentConfig) -> list[AnyMessage]: + """运行直到没有工具调用且没有 follow-up 消息。""" + + if cfg.model is None: + raise ValueError("agent: model is required") + if not cfg.tools: + raise ValueError("agent: at least one tool is required") + + tool_node = ToolNode(cfg.tools) + graph = _build_graph(cfg, tool_node) + + history = list(messages) + pending = _drain(cfg.get_steering_messages) + if pending: + history.extend(pending) + + turn_count = 0 + # 外层循环:仅当存在 follow-up 消息时继续下一轮。 + while True: + result = graph.invoke( + { + "messages": history, + "turn_count": turn_count, + "max_turns": cfg.max_turns, + "has_tool_calls": False, + "pending_steering": [], + "active_turn": 0, + } + ) + history = result["messages"] + turn_count = result["turn_count"] + + followups = _drain(cfg.get_followup_messages) + if not followups: + break + history.extend(followups) + + return history + + +def _build_graph(cfg: AgentConfig, tool_node: ToolNode): + """构建内层循环图:llm_call -> tool_exec -> llm_call(或结束)。""" + + def llm_call(state: LoopState) -> LoopState: + turn = state["turn_count"] + if turn >= state["max_turns"]: + raise RuntimeError(f"agent: max turns ({state['max_turns']}) reached") + + if cfg.hooks.on_turn_start is not None: + cfg.hooks.on_turn_start(turn) + + # 每次模型调用前可对消息做变换(例如上下文压缩)。 + messages = state["messages"] + if cfg.transform_context is not None: + messages = cfg.transform_context(messages) + + assistant = _stream_to_message(cfg.model, messages, cfg.hooks.on_assistant_delta) + if cfg.hooks.on_assistant is not None: + cfg.hooks.on_assistant(assistant) + + has_tool_calls = bool(assistant.tool_calls) + pending_steering: list[AnyMessage] = [] + if not has_tool_calls: + pending_steering = _drain(cfg.get_steering_messages) + if cfg.hooks.on_turn_end is not None: + cfg.hooks.on_turn_end(turn) + + return { + "messages": [*messages, assistant], + "turn_count": turn + 1, + "max_turns": state["max_turns"], + "has_tool_calls": has_tool_calls, + "pending_steering": pending_steering, + "active_turn": turn, + } + + def tool_exec(state: LoopState) -> LoopState: + messages = state["messages"] + last = messages[-1] + if not isinstance(last, AIMessage): + raise RuntimeError("agent: last message is not AIMessage before tool execution") + + tool_calls = list(last.tool_calls or []) + results: list[ToolMessage] = [] + pending: list[AnyMessage] = [] + + # 串行执行 tool call,便于在每次调用后检查 steering。 + for index, call in enumerate(tool_calls): + if cfg.hooks.on_tool_call is not None: + cfg.hooks.on_tool_call(call) + + single_call_message = AIMessage(content="", tool_calls=[call]) + try: + raw = tool_node.invoke({"messages": [single_call_message]}) + tool_messages = _extract_tool_messages(raw) + except Exception as exc: # noqa: BLE001 + tool_messages = [ + ToolMessage( + content=f"tool {call.get('name', '')} failed: {exc}", + tool_call_id=call.get("id", ""), + name=call.get("name"), + ) + ] + + for tool_message in tool_messages: + results.append(tool_message) + if cfg.hooks.on_tool_result is not None: + cfg.hooks.on_tool_result(tool_message) + + # 若检测到 steering,本轮剩余 tool call 直接跳过。 + steering = _drain(cfg.get_steering_messages) + if steering: + pending = steering + for skipped in tool_calls[index + 1 :]: + results.append( + ToolMessage( + content="Skipped due to queued user message.", + tool_call_id=skipped.get("id", ""), + name=skipped.get("name"), + ) + ) + break + + if cfg.hooks.on_turn_end is not None: + cfg.hooks.on_turn_end(state["active_turn"]) + + return { + "messages": [*messages, *results], + "turn_count": state["turn_count"], + "max_turns": state["max_turns"], + "has_tool_calls": False, + "pending_steering": pending, + "active_turn": state["active_turn"], + } + + def inject_steering(state: LoopState) -> LoopState: + return { + "messages": [*state["messages"], *state["pending_steering"]], + "turn_count": state["turn_count"], + "max_turns": state["max_turns"], + "has_tool_calls": False, + "pending_steering": [], + "active_turn": state["active_turn"], + } + + def route_after_llm(state: LoopState) -> Literal["tool_exec", "inject_steering", END]: + if state["has_tool_calls"]: + return "tool_exec" + if state["pending_steering"]: + return "inject_steering" + return END + + def route_after_tools(state: LoopState) -> Literal["inject_steering", "llm_call"]: + if state["pending_steering"]: + return "inject_steering" + return "llm_call" + + builder = StateGraph(LoopState) + builder.add_node("llm_call", llm_call) + builder.add_node("tool_exec", tool_exec) + builder.add_node("inject_steering", inject_steering) + builder.add_edge(START, "llm_call") + builder.add_conditional_edges("llm_call", route_after_llm, {"tool_exec": "tool_exec", "inject_steering": "inject_steering", END: END}) + builder.add_conditional_edges("tool_exec", route_after_tools, {"inject_steering": "inject_steering", "llm_call": "llm_call"}) + builder.add_edge("inject_steering", "llm_call") + return builder.compile() + + +def _stream_to_message( + model: object, + messages: list[AnyMessage], + on_assistant_delta: Callable[[str], None] | None = None, +) -> AIMessage: + """流式拉取模型输出并合并为单条 AIMessage。 + + 对部分仅“半支持”流式的网关,自动回退到非流式 invoke。 + """ + + chunks: list[AnyMessage] = [] + try: + for chunk in model.stream(messages): + if on_assistant_delta is not None: + delta_text = _extract_text_content(getattr(chunk, "content", "")) + if delta_text: + on_assistant_delta(delta_text) + chunks.append(chunk) + except Exception: + # 某些 OpenAI 兼容网关流式不完整,回退到 invoke。 + invoked = model.invoke(messages) + if isinstance(invoked, AIMessage): + return invoked + raise + if not chunks: + invoked = model.invoke(messages) + if isinstance(invoked, AIMessage): + return invoked + raise RuntimeError("agent: model stream returned no chunks") + + merged = chunks[0] + for chunk in chunks[1:]: + merged = merged + chunk + + if isinstance(merged, AIMessage): + return merged + + if hasattr(merged, "to_message"): + converted = merged.to_message() + if isinstance(converted, AIMessage): + return converted + + content = getattr(merged, "content", "") + tool_calls = getattr(merged, "tool_calls", []) + additional_kwargs = getattr(merged, "additional_kwargs", {}) + response_metadata = getattr(merged, "response_metadata", {}) + message_id = getattr(merged, "id", None) + return AIMessage( + content=content, + tool_calls=tool_calls, + additional_kwargs=additional_kwargs, + response_metadata=response_metadata, + id=message_id, + ) + + +def _extract_text_content(content: object) -> str: + """从字符串或结构化内容块中提取可展示文本。""" + + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + continue + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + continue + nested = item.get("content") + if isinstance(nested, str): + parts.append(nested) + continue + text_attr = getattr(item, "text", None) + if isinstance(text_attr, str): + parts.append(text_attr) + continue + content_attr = getattr(item, "content", None) + if isinstance(content_attr, str): + parts.append(content_attr) + + return "".join(parts).strip() + + +def _extract_tool_messages(raw: object) -> list[ToolMessage]: + """将 ToolNode 输出统一归一化为 ToolMessage 列表。""" + + if isinstance(raw, dict): + messages = raw.get("messages", []) + elif isinstance(raw, list): + messages = raw + else: + raise RuntimeError(f"unexpected ToolNode output type: {type(raw)!r}") + + tool_messages: list[ToolMessage] = [] + for message in messages: + if isinstance(message, ToolMessage): + tool_messages.append(message) + return tool_messages + + +def _drain(fn: Callable[[], list[AnyMessage]] | None) -> list[AnyMessage]: + """从回调中取出排队消息;未提供回调则返回空列表。""" + + if fn is None: + return [] + return fn() + diff --git a/app/agentland-agent/app/services/chat_router.py b/app/agentland-agent/app/services/chat_router.py new file mode 100644 index 0000000..8e7bbfd --- /dev/null +++ b/app/agentland-agent/app/services/chat_router.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +"""统一 chat 接口的极简路由层。""" + +import json +import threading +from typing import Literal + +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI + + +_router_lock = threading.Lock() +_router_cache: dict[tuple[str, str | None, float], object] = {} + + +def route_prompt( + *, + messages: list[AnyMessage], + api_key: str, + model: str, + base_url: str | None, + timeout: float, +) -> Literal["chat", "task"]: + """根据历史对话与当前用户消息,直接返回路由 intent。""" + + return _invoke_router_model( + messages=messages, + api_key=api_key, + model=model, + base_url=base_url, + timeout=timeout, + ) + + +def _invoke_router_model( + *, + messages: list[AnyMessage], + api_key: str, + model: str, + base_url: str | None, + timeout: float, +) -> Literal["chat", "task"]: + router = _get_router_model(model=model, api_key=api_key, base_url=base_url, timeout=timeout) + content = _collect_streamed_content( + router, + [ + SystemMessage( + content=( + "You are a router for a coding agent.\n" + "Read the conversation history and classify the latest user intent.\n" + "Return intent='chat' when the user mainly wants an answer, explanation, discussion, or advice.\n" + "Return intent='task' when the user wants the agent to execute a multi-step task, modify files, run tools, or operate on a workspace.\n" + 'Reply with JSON only in this exact shape: {"intent":"chat|task","reason":"string"}' + ) + ), + *_normalize_router_messages(messages), + ], + ) + payload = json.loads(content) + return payload["intent"] + + +def _get_router_model(*, model: str, api_key: str, base_url: str | None, timeout: float): + cache_key = (model, base_url, timeout) + with _router_lock: + router = _router_cache.get(cache_key) + if router is not None: + return router + + router = ChatOpenAI( + model=model, + api_key=api_key, + base_url=base_url, + timeout=timeout, + streaming=True, + max_retries=1, + use_responses_api=False, + ) + _router_cache[cache_key] = router + return router + + +def _normalize_router_messages(messages: list[AnyMessage]) -> list[AnyMessage]: + """路由仅保留会影响意图判断的用户/助手文本消息。""" + + normalized: list[AnyMessage] = [] + for message in messages: + if isinstance(message, HumanMessage): + content = _extract_text_content(message.content) + if content: + normalized.append(HumanMessage(content=content)) + continue + if isinstance(message, AIMessage): + content = _extract_text_content(message.content) + if content: + normalized.append(AIMessage(content=content)) + return normalized + + +def _collect_streamed_content(model: object, messages: list[AnyMessage]) -> str: + chunks: list[object] = [] + for chunk in model.stream(messages): + chunks.append(chunk) + + if not chunks: + raise RuntimeError("router stream returned no chunks") + + merged = chunks[0] + for chunk in chunks[1:]: + merged = merged + chunk + + return _extract_text_content(getattr(merged, "content", "")).strip() + + +def _extract_text_content(content: object) -> str: + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + continue + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + continue + nested = item.get("content") + if isinstance(nested, str): + parts.append(nested) + continue + text_attr = getattr(item, "text", None) + if isinstance(text_attr, str): + parts.append(text_attr) + continue + content_attr = getattr(item, "content", None) + if isinstance(content_attr, str): + parts.append(content_attr) + return "".join(parts).strip() diff --git a/app/agentland-agent/app/services/chat_service.py b/app/agentland-agent/app/services/chat_service.py new file mode 100644 index 0000000..c737ca2 --- /dev/null +++ b/app/agentland-agent/app/services/chat_service.py @@ -0,0 +1,469 @@ +from __future__ import annotations + +"""统一 chat 服务:graph 路由 + pi 风格会话记忆 + SSE 输出。""" + +import asyncio +import json +import os +import queue +import threading +import uuid +from collections.abc import Callable +from pathlib import Path +from typing import Literal + +from fastapi import HTTPException +from fastapi.responses import StreamingResponse +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, SystemMessage, ToolMessage +from langchain_openai import ChatOpenAI + +from app.models.session import SessionState +from app.schemas.chat import ChatStreamRequest +from app.schemas.ralph import RalphStreamRequest +from app.services.agent_loop import AgentConfig, Hooks, run_agent +from app.services.chat_router import route_prompt +from app.services.memory_compaction import ( + CompactionResult, + compact_session, + detect_context_overflow, + maybe_auto_compact, +) +from app.services.ralph_service import run_ralph +from app.services.session_memory import SessionManager +from app.services.skills_service import inject_skills_into_messages +from app.services.tools import load_tools, tool_signature +_sessions: dict[str, SessionState] = {} +_sessions_lock = threading.Lock() +_model_cache: dict[tuple[str, str | None, float, tuple[str, ...]], object] = {} +_model_cache_lock = threading.Lock() + + +async def stream_chat(request: ChatStreamRequest) -> StreamingResponse: + """执行一次统一 chat 请求并返回 SSE StreamingResponse。""" + + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise HTTPException(status_code=500, detail="OPENAI_API_KEY not set") + + session_id = request.session_id or f"session-{uuid.uuid4().hex[:8]}" + async_queue: asyncio.Queue[dict[str, object] | None] = asyncio.Queue() + loop = asyncio.get_running_loop() + + def emit(event: str, data: dict[str, object]) -> None: + loop.call_soon_threadsafe(async_queue.put_nowait, {"event": event, "data": data}) + + def worker() -> None: + try: + session = _get_or_create_session( + session_id=session_id, + workspace_path=request.workspace_path, + system_prompt=request.system, + ) + if request.deep: + decision = route_prompt( + messages=[*session.manager.build_session_context(), HumanMessage(content=request.message)], + api_key=api_key, + model=request.model, + base_url=request.base_url, + timeout=request.timeout, + ) + else: + decision = "chat" + emit("route", {"intent": decision}) + + if decision == "task": + _run_task_branch(request=request, session_id=session_id, emit=emit) + else: + _run_chat_branch(request=request, session_id=session_id, api_key=api_key, emit=emit) + except HTTPException as exc: + emit("error", {"status_code": exc.status_code, "message": exc.detail}) + except Exception as exc: # noqa: BLE001 + emit("error", {"message": str(exc)}) + finally: + loop.call_soon_threadsafe(async_queue.put_nowait, None) + + threading.Thread(target=worker, daemon=True).start() + + async def event_stream(): + while True: + try: + item = await asyncio.wait_for(async_queue.get(), timeout=15.0) + except TimeoutError: + yield _sse("ping", {"ts": int(asyncio.get_running_loop().time())}) + continue + if item is None: + break + event = str(item["event"]) + data = item["data"] + if isinstance(data, dict): + yield _sse(event, data) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +def queue_steering(session_id: str, message: str) -> dict[str, object]: + """向指定会话写入 steering 消息。""" + + session = _get_session_or_404(session_id) + _queue_message(session.steering_queue, HumanMessage(content=message)) + return {"ok": True, "session_id": session.session_id} + + +def queue_followup(session_id: str, message: str) -> dict[str, object]: + """向指定会话写入 follow-up 消息。""" + + session = _get_session_or_404(session_id) + _queue_message(session.followup_queue, HumanMessage(content=message)) + return {"ok": True, "session_id": session.session_id} + + +def _run_chat_branch( + *, + request: ChatStreamRequest, + session_id: str, + api_key: str, + emit: Callable[[str, dict[str, object]], None], +) -> None: + session = _get_or_create_session( + session_id=session_id, + workspace_path=request.workspace_path, + system_prompt=request.system, + ) + + with session.lock: + if session.running: + raise HTTPException(status_code=409, detail="session is already running") + session.running = True + + try: + emit( + "session", + { + "session_id": session.session_id, + "mode": "chat", + "workspace_path": str(session.workspace_path), + "session_file": str(session.manager.session_file), + }, + ) + + pre_result = maybe_auto_compact( + manager=session.manager, + model=request.model, + api_key=api_key, + base_url=request.base_url, + timeout=request.timeout, + ) + _emit_auto_compaction(emit=emit, result=pre_result, reason="threshold", will_retry=False) + + human_message = HumanMessage(content=request.message) + session.manager.append_message(human_message) + tools = load_tools() + + model = _call_get_bound_model( + api_key=api_key, + model=request.model, + base_url=request.base_url, + timeout=request.timeout, + tools=tools, + ) + stream_state = {"has_delta": False} + + def emit_assistant_delta(text: str) -> None: + if not text: + return + stream_state["has_delta"] = True + emit("assistant_delta", {"content": text}) + + def run_once() -> list[AnyMessage]: + local_history = inject_skills_into_messages(session.manager.build_session_context(), session.workspace_path) + out = run_agent( + local_history, + AgentConfig( + model=model, + tools=tools, + max_turns=request.max_turns, + get_steering_messages=lambda: _drain_queue(session.steering_queue, request.queue_mode), + get_followup_messages=lambda: _drain_queue(session.followup_queue, request.queue_mode), + hooks=Hooks( + on_assistant_delta=emit_assistant_delta, + on_assistant=lambda message: _emit_assistant_fallback(emit_assistant_delta, message, stream_state), + on_tool_call=lambda call: emit( + "tool_call", + { + "id": call.get("id", ""), + "name": call.get("name", ""), + "args": call.get("args", {}), + }, + ), + on_tool_result=lambda message: emit( + "tool_result", + { + "tool_call_id": message.tool_call_id or "", + "name": message.name or "", + "content": _normalize_tool_content(message.content), + }, + ), + ), + ), + ) + for message in out[len(local_history) :]: + session.manager.append_message(message) + return out + + try: + out = run_once() + except Exception as exc: + if not detect_context_overflow(exc): + raise + overflow_result = compact_session( + manager=session.manager, + model=request.model, + api_key=api_key, + base_url=request.base_url, + timeout=request.timeout, + ) + _emit_auto_compaction(emit=emit, result=overflow_result, reason="overflow", will_retry=True) + if overflow_result is None: + raise + out = run_once() + + post_result = maybe_auto_compact( + manager=session.manager, + model=request.model, + api_key=api_key, + base_url=request.base_url, + timeout=request.timeout, + ) + _emit_auto_compaction(emit=emit, result=post_result, reason="threshold", will_retry=False) + + emit("done", {"session_id": session.session_id, "mode": "chat"}) + finally: + with session.lock: + session.running = False + + +def _run_task_branch( + *, + request: ChatStreamRequest, + session_id: str, + emit: Callable[[str, dict[str, object]], None], +) -> None: + run_ralph( + request=RalphStreamRequest( + requirement=request.message, + workspace_path=request.workspace_path, + session_id=session_id, + project_name=request.project_name, + model=request.model, + base_url=request.base_url, + timeout=request.timeout, + agent_max_turns=request.agent_max_turns, + iterations=request.iterations, + ), + emit=emit, + ) + + +def _get_or_create_session(*, session_id: str, workspace_path: str | None, system_prompt: str) -> SessionState: + resolved_workspace = Path(workspace_path or os.getcwd()).expanduser().resolve() + + with _sessions_lock: + session = _sessions.get(session_id) + if session is not None: + if session.workspace_path != resolved_workspace: + raise HTTPException(status_code=400, detail="session_id already exists for a different workspace") + session.manager = SessionManager.open(session.manager.session_file) + return session + + manager = SessionManager.open_or_create( + cwd=resolved_workspace, + session_id=session_id, + system_prompt=system_prompt, + ) + session = SessionState( + session_id=session_id, + workspace_path=resolved_workspace, + manager=manager, + ) + _sessions[session_id] = session + return session + + +def _get_session_or_404(session_id: str) -> SessionState: + with _sessions_lock: + session = _sessions.get(session_id) + if session is None: + raise HTTPException(status_code=404, detail="session not found") + return session + + +def _queue_message(q: queue.Queue[AnyMessage], message: AnyMessage) -> None: + try: + q.put_nowait(message) + except queue.Full as exc: + raise HTTPException(status_code=429, detail="queue is full") from exc + + +def _drain_queue(q: queue.Queue[AnyMessage], mode: Literal["one-at-a-time", "all"]) -> list[AnyMessage]: + if mode == "all": + out: list[AnyMessage] = [] + while True: + try: + out.append(q.get_nowait()) + except queue.Empty: + return out + + try: + return [q.get_nowait()] + except queue.Empty: + return [] + + +def _get_bound_model(*, api_key: str, model: str, base_url: str | None, timeout: float, tools: list[object]): + key = (model, base_url, timeout, tool_signature(tools)) + with _model_cache_lock: + bound = _model_cache.get(key) + if bound is not None: + return bound + + llm = ChatOpenAI( + model=model, + api_key=api_key, + base_url=base_url, + streaming=True, + timeout=timeout, + max_retries=1, + stream_usage=False, + use_responses_api=False, + ) + bound = llm.bind_tools(tools) + _model_cache[key] = bound + return bound + + +def _call_get_bound_model( + *, + api_key: str, + model: str, + base_url: str | None, + timeout: float, + tools: list[object], +): + """兼容旧测试桩:tools 参数不可用时回退到旧签名。""" + + try: + return _get_bound_model( + api_key=api_key, + model=model, + base_url=base_url, + timeout=timeout, + tools=tools, + ) + except TypeError as exc: + if "tools" not in str(exc): + raise + return _get_bound_model( + api_key=api_key, + model=model, + base_url=base_url, + timeout=timeout, + ) + + +def _emit_assistant_fallback( + emit_assistant_delta: Callable[[str], None], + message: AIMessage, + stream_state: dict[str, bool], +) -> None: + if stream_state.get("has_delta", False): + return + + content = _extract_text_content(message.content) + if content: + emit_assistant_delta(content) + return + + refusal = message.additional_kwargs.get("refusal") + if isinstance(refusal, str) and refusal: + emit_assistant_delta(refusal) + + +def _extract_text_content(content: object) -> str: + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + continue + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + continue + nested = item.get("content") + if isinstance(nested, str): + parts.append(nested) + continue + text_attr = getattr(item, "text", None) + if isinstance(text_attr, str): + parts.append(text_attr) + continue + content_attr = getattr(item, "content", None) + if isinstance(content_attr, str): + parts.append(content_attr) + + return "".join(parts).strip() + + +def _normalize_tool_content(content: object) -> object: + if isinstance(content, list): + parts: list[object] = [] + for item in content: + if isinstance(item, dict): + parts.append(item) + else: + parts.append({"type": "text", "text": str(item)}) + return parts + return content + + +def _emit_auto_compaction( + *, + emit: Callable[[str, dict[str, object]], None], + result: CompactionResult | None, + reason: Literal["threshold", "overflow"], + will_retry: bool, +) -> None: + if result is None: + return + emit("auto_compaction_start", {"reason": reason}) + emit( + "auto_compaction_end", + { + "reason": reason, + "aborted": False, + "will_retry": will_retry, + "result": { + "summary": result.summary, + "first_kept_entry_id": result.first_kept_entry_id, + "tokens_before": result.tokens_before, + "details": result.details, + }, + }, + ) + + +def _sse(event: str, data: dict[str, object]) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" diff --git a/app/agentland-agent/app/services/mcp_config.py b/app/agentland-agent/app/services/mcp_config.py new file mode 100644 index 0000000..03ef534 --- /dev/null +++ b/app/agentland-agent/app/services/mcp_config.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +"""MCP server 配置。""" + +import json +import os +import re +from pathlib import Path +from typing import Literal + +from pydantic import BaseModel, Field, ValidationError, model_validator + +type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] + +MCP_CONFIG_ENV = "AGENTLAND_AGENT_MCP_CONFIG" +MCP_CONFIG_PATH_ENV = "AGENTLAND_AGENT_MCP_CONFIG_PATH" + + +class McpServerConfig(BaseModel): + """单个 MCP server 的连接配置。""" + + transport: Literal["stdio", "streamable_http", "sse"] + command: str | None = None + args: list[str] = Field(default_factory=list) + url: str | None = None + headers: dict[str, str] = Field(default_factory=dict) + env: dict[str, str] = Field(default_factory=dict) + cwd: str | None = None + encoding: str | None = None + encoding_error_handler: str | None = None + timeout: float | None = None + sse_read_timeout: float | None = None + session_kwargs: dict[str, JsonValue] = Field(default_factory=dict) + enabled: bool = True + + @model_validator(mode="after") + def validate_transport_fields(self) -> "McpServerConfig": + if self.transport == "stdio" and not self.command: + raise ValueError("stdio transport requires command") + if self.transport in {"streamable_http", "sse"} and not self.url: + raise ValueError(f"{self.transport} transport requires url") + return self + + def to_client_dict(self) -> dict[str, JsonValue]: + """转换为 MultiServerMCPClient 可接受的 dict。""" + + payload = self.model_dump(exclude_none=True, exclude={"enabled"}, mode="json") + return {key: _expand_json_value(value) for key, value in payload.items()} + + +# 在这里直接写死需要接入的 MCP 服务即可。 +# 例如: +# DEFAULT_MCP_SERVER_CONFIGS = { +# "filesystem": McpServerConfig( +# transport="stdio", +# command="uvx", +# args=["mcp-server-filesystem", "/absolute/path/to/workspace"], +# ), +# "weather": McpServerConfig( +# transport="streamable_http", +# url="http://127.0.0.1:8001/mcp", +# headers={"Authorization": f"Bearer {os.getenv('WEATHER_API_KEY', '')}"}, +# ), +# } +DEFAULT_MCP_SERVER_CONFIGS: dict[str, McpServerConfig] = {} + + +def load_mcp_server_configs() -> dict[str, dict[str, JsonValue]]: + """从代码常量和环境变量加载 MCP server 配置。""" + + merged: dict[str, McpServerConfig] = dict(DEFAULT_MCP_SERVER_CONFIGS) + + raw_path = os.getenv(MCP_CONFIG_PATH_ENV) + if raw_path: + merged.update(_parse_config_mapping(Path(raw_path).expanduser().resolve().read_text(encoding="utf-8"))) + + raw_inline = os.getenv(MCP_CONFIG_ENV) + if raw_inline: + merged.update(_parse_config_mapping(raw_inline)) + + return { + name: config.to_client_dict() + for name, config in merged.items() + if config.enabled + } + + +def _parse_config_mapping(raw: str) -> dict[str, McpServerConfig]: + document = json.loads(raw) + if not isinstance(document, dict): + raise ValueError("MCP config must be a JSON object keyed by server name") + + parsed: dict[str, McpServerConfig] = {} + for name, value in document.items(): + if not isinstance(name, str): + raise ValueError("MCP config keys must be strings") + if not isinstance(value, dict): + raise ValueError(f"MCP config for {name!r} must be an object") + try: + parsed[name] = McpServerConfig.model_validate(value) + except ValidationError as exc: + raise ValueError(f"invalid MCP config for {name!r}: {exc}") from exc + return parsed + + +def _expand_json_value(value: JsonValue) -> JsonValue: + if isinstance(value, str): + return _expand_env_vars(value) + if isinstance(value, list): + return [_expand_json_value(item) for item in value] + if isinstance(value, dict): + return {key: _expand_json_value(item) for key, item in value.items()} + return value + + +def _expand_env_vars(value: str) -> str: + return re.sub(r"\$\{([^}]+)\}", lambda match: os.getenv(match.group(1), ""), value) diff --git a/app/agentland-agent/app/services/memory_compaction.py b/app/agentland-agent/app/services/memory_compaction.py new file mode 100644 index 0000000..da5aae4 --- /dev/null +++ b/app/agentland-agent/app/services/memory_compaction.py @@ -0,0 +1,896 @@ +from __future__ import annotations + +"""pi-mono 风格的会话记忆压缩。""" + +import json +import math +import os +from dataclasses import dataclass +from typing import cast + +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, SystemMessage, ToolMessage +from langchain_openai import ChatOpenAI + +from app.services.session_memory import JsonValue, SessionManager + +_TOOL_RESULT_MAX_CHARS = 2000 +_DEFAULT_CONTEXT_WINDOW = 128000 +_CONTEXT_WINDOW_ENV = "AGENTLAND_CONTEXT_WINDOW" +_COMPACTION_ENABLED_ENV = "AGENTLAND_COMPACTION_ENABLED" +_RESERVE_TOKENS_ENV = "AGENTLAND_COMPACTION_RESERVE_TOKENS" +_KEEP_RECENT_TOKENS_ENV = "AGENTLAND_COMPACTION_KEEP_RECENT_TOKENS" + +_SUMMARIZATION_SYSTEM_PROMPT = ( + "You are a context summarization assistant. Your task is to read a conversation between a user and an AI " + "coding assistant, then produce a structured summary following the exact format specified.\n\n" + "Do NOT continue the conversation. Do NOT respond to any questions in the conversation. " + "ONLY output the structured summary." +) + +_SUMMARIZATION_PROMPT = """The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work. + +Use this EXACT format: + +## Goal +[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.] + +## Constraints & Preferences +- [Any constraints, preferences, or requirements mentioned by user] +- [Or "(none)" if none were mentioned] + +## Progress +### Done +- [x] [Completed tasks/changes] + +### In Progress +- [ ] [Current work] + +### Blocked +- [Issues preventing progress, if any] + +## Key Decisions +- **[Decision]**: [Brief rationale] + +## Next Steps +1. [Ordered list of what should happen next] + +## Critical Context +- [Any data, examples, or references needed to continue] +- [Or "(none)" if not applicable] + +Keep each section concise. Preserve exact file paths, function names, and error messages.""" + +_UPDATE_SUMMARIZATION_PROMPT = """The messages above are NEW conversation messages to incorporate into the existing summary provided in tags. + +Update the existing structured summary with new information. RULES: +- PRESERVE all existing information from the previous summary +- ADD new progress, decisions, and context from the new messages +- UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- UPDATE "Next Steps" based on what was accomplished +- PRESERVE exact file paths, function names, and error messages +- If something is no longer relevant, you may remove it + +Use this EXACT format: + +## Goal +[Preserve existing goals, add new ones if the task expanded] + +## Constraints & Preferences +- [Preserve existing, add new ones discovered] + +## Progress +### Done +- [x] [Include previously done items AND newly completed items] + +### In Progress +- [ ] [Current work - update based on progress] + +### Blocked +- [Current blockers - remove if resolved] + +## Key Decisions +- **[Decision]**: [Brief rationale] (preserve all previous, add new) + +## Next Steps +1. [Update based on current state] + +## Critical Context +- [Preserve important context, add new if needed] + +Keep each section concise. Preserve exact file paths, function names, and error messages.""" + +_TURN_PREFIX_SUMMARIZATION_PROMPT = """This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. + +Summarize the prefix to provide context for the retained suffix: + +## Original Request +[What did the user ask for in this turn?] + +## Early Progress +- [Key decisions and work done in the prefix] + +## Context for Suffix +- [Information needed to understand the retained recent work] + +Be concise. Focus on what's needed to understand the kept suffix.""" + + +@dataclass(slots=True) +class CompactionSettings: + enabled: bool = True + reserve_tokens: int = 16384 + keep_recent_tokens: int = 20000 + + +@dataclass(slots=True) +class ContextUsageEstimate: + tokens: int + usage_tokens: int + trailing_tokens: int + last_usage_index: int | None + + +@dataclass(slots=True) +class FileOperations: + read: set[str] + written: set[str] + edited: set[str] + + +@dataclass(slots=True) +class CutPointResult: + first_kept_entry_index: int + turn_start_index: int + is_split_turn: bool + + +@dataclass(slots=True) +class CompactionPreparation: + first_kept_entry_id: str + messages_to_summarize: list[AnyMessage] + turn_prefix_messages: list[AnyMessage] + is_split_turn: bool + tokens_before: int + previous_summary: str | None + file_ops: FileOperations + settings: CompactionSettings + + +@dataclass(slots=True) +class CompactionResult: + summary: str + first_kept_entry_id: str + tokens_before: int + details: dict[str, JsonValue] + + +def load_compaction_settings() -> CompactionSettings: + return CompactionSettings( + enabled=_env_bool(_COMPACTION_ENABLED_ENV, True), + reserve_tokens=_env_int(_RESERVE_TOKENS_ENV, 16384), + keep_recent_tokens=_env_int(_KEEP_RECENT_TOKENS_ENV, 20000), + ) + + +def resolve_context_window() -> int: + return _env_int(_CONTEXT_WINDOW_ENV, _DEFAULT_CONTEXT_WINDOW) + + +def should_compact(context_tokens: int, context_window: int, settings: CompactionSettings) -> bool: + if not settings.enabled: + return False + return context_tokens > context_window - settings.reserve_tokens + + +def detect_context_overflow(exc: Exception) -> bool: + text = str(exc).lower() + return any( + marker in text + for marker in ( + "context length", + "maximum context length", + "maximum context window", + "context window", + "too many tokens", + "prompt is too long", + ) + ) + + +def estimate_context_tokens(messages: list[AnyMessage]) -> ContextUsageEstimate: + usage_info = _get_last_assistant_usage_info(messages) + if usage_info is None: + estimated = sum(estimate_tokens(message) for message in messages) + return ContextUsageEstimate( + tokens=estimated, + usage_tokens=0, + trailing_tokens=estimated, + last_usage_index=None, + ) + + usage_tokens, index = usage_info + trailing_tokens = sum(estimate_tokens(message) for message in messages[index + 1 :]) + return ContextUsageEstimate( + tokens=usage_tokens + trailing_tokens, + usage_tokens=usage_tokens, + trailing_tokens=trailing_tokens, + last_usage_index=index, + ) + + +def maybe_auto_compact( + *, + manager: SessionManager, + model: str, + api_key: str, + base_url: str | None, + timeout: float, +) -> CompactionResult | None: + settings = load_compaction_settings() + if not settings.enabled: + return None + + context_tokens = estimate_context_tokens(manager.build_session_context()).tokens + if not should_compact(context_tokens, resolve_context_window(), settings): + return None + + return compact_session( + manager=manager, + model=model, + api_key=api_key, + base_url=base_url, + timeout=timeout, + settings=settings, + ) + + +def compact_session( + *, + manager: SessionManager, + model: str, + api_key: str, + base_url: str | None, + timeout: float, + settings: CompactionSettings | None = None, + custom_instructions: str | None = None, +) -> CompactionResult | None: + effective_settings = settings or load_compaction_settings() + preparation = prepare_compaction(manager.get_branch(), effective_settings) + if preparation is None: + return None + + result = build_compaction_result( + preparation=preparation, + model=model, + api_key=api_key, + base_url=base_url, + timeout=timeout, + custom_instructions=custom_instructions, + ) + manager.append_compaction( + summary=result.summary, + first_kept_entry_id=result.first_kept_entry_id, + tokens_before=result.tokens_before, + details=result.details, + ) + return result + + +def prepare_compaction( + entries: list[dict[str, object]], + settings: CompactionSettings, +) -> CompactionPreparation | None: + if not entries: + return None + if str(entries[-1].get("type")) == "compaction": + return None + + prev_compaction_index = _find_previous_compaction_index(entries) + boundary_start = prev_compaction_index + 1 + if prev_compaction_index < 0: + boundary_start = _skip_leading_system_messages(entries, boundary_start) + boundary_end = len(entries) + if boundary_start >= boundary_end: + return None + + usage_start = prev_compaction_index if prev_compaction_index >= 0 else 0 + usage_messages = [ + message + for entry in entries[usage_start:boundary_end] + if (message := _get_message_from_entry(entry)) is not None + ] + tokens_before = estimate_context_tokens(usage_messages).tokens + + cut_point = find_cut_point(entries, boundary_start, boundary_end, settings.keep_recent_tokens) + first_kept_entry = entries[cut_point.first_kept_entry_index] + first_kept_entry_id = str(first_kept_entry["id"]) + + history_end = cut_point.turn_start_index if cut_point.is_split_turn else cut_point.first_kept_entry_index + messages_to_summarize = [ + message + for entry in entries[boundary_start:history_end] + if (message := _get_message_from_entry(entry)) is not None + ] + + turn_prefix_messages: list[AnyMessage] = [] + if cut_point.is_split_turn: + for entry in entries[cut_point.turn_start_index : cut_point.first_kept_entry_index]: + message = _get_message_from_entry(entry) + if message is not None: + turn_prefix_messages.append(message) + + if not messages_to_summarize and not turn_prefix_messages: + return None + + previous_summary: str | None = None + if prev_compaction_index >= 0: + previous_summary = str(entries[prev_compaction_index].get("summary", "")).strip() or None + + file_ops = _extract_file_operations( + messages=messages_to_summarize, + entries=entries, + prev_compaction_index=prev_compaction_index, + ) + for message in turn_prefix_messages: + extract_file_ops_from_message(message, file_ops) + + return CompactionPreparation( + first_kept_entry_id=first_kept_entry_id, + messages_to_summarize=messages_to_summarize, + turn_prefix_messages=turn_prefix_messages, + is_split_turn=cut_point.is_split_turn, + tokens_before=tokens_before, + previous_summary=previous_summary, + file_ops=file_ops, + settings=settings, + ) + + +def build_compaction_result( + *, + preparation: CompactionPreparation, + model: str, + api_key: str, + base_url: str | None, + timeout: float, + custom_instructions: str | None = None, +) -> CompactionResult: + if preparation.is_split_turn and preparation.turn_prefix_messages: + history_summary = ( + generate_summary( + current_messages=preparation.messages_to_summarize, + model=model, + reserve_tokens=preparation.settings.reserve_tokens, + api_key=api_key, + base_url=base_url, + timeout=timeout, + custom_instructions=custom_instructions, + previous_summary=preparation.previous_summary, + ) + if preparation.messages_to_summarize + else "No prior history." + ) + turn_prefix_summary = generate_turn_prefix_summary( + messages=preparation.turn_prefix_messages, + model=model, + reserve_tokens=preparation.settings.reserve_tokens, + api_key=api_key, + base_url=base_url, + timeout=timeout, + ) + summary = f"{history_summary}\n\n---\n\n**Turn Context (split turn):**\n\n{turn_prefix_summary}" + else: + summary = generate_summary( + current_messages=preparation.messages_to_summarize, + model=model, + reserve_tokens=preparation.settings.reserve_tokens, + api_key=api_key, + base_url=base_url, + timeout=timeout, + custom_instructions=custom_instructions, + previous_summary=preparation.previous_summary, + ) + + read_files, modified_files = compute_file_lists(preparation.file_ops) + summary += format_file_operations(read_files, modified_files) + return CompactionResult( + summary=summary, + first_kept_entry_id=preparation.first_kept_entry_id, + tokens_before=preparation.tokens_before, + details={ + "readFiles": read_files, + "modifiedFiles": modified_files, + }, + ) + + +def generate_summary( + *, + current_messages: list[AnyMessage], + model: str, + reserve_tokens: int, + api_key: str, + base_url: str | None, + timeout: float, + custom_instructions: str | None = None, + previous_summary: str | None = None, +) -> str: + base_prompt = _UPDATE_SUMMARIZATION_PROMPT if previous_summary else _SUMMARIZATION_PROMPT + if custom_instructions: + base_prompt = f"{base_prompt}\n\nAdditional focus: {custom_instructions}" + + conversation_text = serialize_conversation(current_messages) + prompt_text = f"\n{conversation_text}\n\n\n" + if previous_summary: + prompt_text += f"\n{previous_summary}\n\n\n" + prompt_text += base_prompt + return _generate_summary_text( + model=model, + api_key=api_key, + base_url=base_url, + timeout=timeout, + prompt_text=prompt_text, + max_tokens=max(256, math.floor(0.8 * reserve_tokens)), + ) + + +def generate_turn_prefix_summary( + *, + messages: list[AnyMessage], + model: str, + reserve_tokens: int, + api_key: str, + base_url: str | None, + timeout: float, +) -> str: + conversation_text = serialize_conversation(messages) + prompt_text = f"\n{conversation_text}\n\n\n{_TURN_PREFIX_SUMMARIZATION_PROMPT}" + return _generate_summary_text( + model=model, + api_key=api_key, + base_url=base_url, + timeout=timeout, + prompt_text=prompt_text, + max_tokens=max(256, math.floor(0.5 * reserve_tokens)), + ) + + +def serialize_conversation(messages: list[AnyMessage]) -> str: + parts: list[str] = [] + for message in messages: + if isinstance(message, HumanMessage): + content = _message_text(message) + if content: + parts.append(f"[User]: {content}") + continue + if isinstance(message, SystemMessage): + content = _message_text(message) + if content: + parts.append(f"[System]: {content}") + continue + if isinstance(message, AIMessage): + text_parts = [_extract_text_content_block(block) for block in _iter_message_blocks(message.content)] + text = "\n".join(part for part in text_parts if part) + if text: + parts.append(f"[Assistant]: {text}") + tool_calls = _serialize_tool_calls(message) + if tool_calls: + parts.append(f"[Assistant tool calls]: {'; '.join(tool_calls)}") + continue + if isinstance(message, ToolMessage): + content = _truncate_for_summary(_message_text(message), _TOOL_RESULT_MAX_CHARS) + if content: + parts.append(f"[Tool result]: {content}") + return "\n\n".join(parts) + + +def create_file_ops() -> FileOperations: + return FileOperations(read=set(), written=set(), edited=set()) + + +def extract_file_ops_from_message(message: AnyMessage, file_ops: FileOperations) -> None: + if not isinstance(message, AIMessage): + return + for tool_call in cast(list[dict[str, object]], message.tool_calls or []): + name = str(tool_call.get("name") or "") + args = tool_call.get("args") + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {} + if not isinstance(args, dict): + continue + path = args.get("path") + if not isinstance(path, str) or not path: + continue + if name == "read": + file_ops.read.add(path) + elif name == "write": + file_ops.written.add(path) + elif name == "edit": + file_ops.edited.add(path) + + +def compute_file_lists(file_ops: FileOperations) -> tuple[list[str], list[str]]: + modified = set(file_ops.written) | set(file_ops.edited) + read_only = sorted(path for path in file_ops.read if path not in modified) + return read_only, sorted(modified) + + +def format_file_operations(read_files: list[str], modified_files: list[str]) -> str: + sections: list[str] = [] + if read_files: + sections.append("\n" + "\n".join(read_files) + "\n") + if modified_files: + sections.append("\n" + "\n".join(modified_files) + "\n") + if not sections: + return "" + return "\n\n" + "\n\n".join(sections) + + +def estimate_tokens(message: AnyMessage) -> int: + if isinstance(message, ToolMessage): + return math.ceil(len(_message_text(message)) / 4) + if isinstance(message, (HumanMessage, SystemMessage)): + return math.ceil(len(_message_text(message)) / 4) + if isinstance(message, AIMessage): + chars = len(_message_text(message)) + for tool_call in cast(list[dict[str, object]], message.tool_calls or []): + chars += len(str(tool_call.get("name") or "")) + chars += len(json.dumps(tool_call.get("args", {}), ensure_ascii=False)) + return math.ceil(chars / 4) + return 0 + + +def find_cut_point( + entries: list[dict[str, object]], + start_index: int, + end_index: int, + keep_recent_tokens: int, +) -> CutPointResult: + cut_points = _find_valid_cut_points(entries, start_index, end_index) + if not cut_points: + return CutPointResult( + first_kept_entry_index=start_index, + turn_start_index=-1, + is_split_turn=False, + ) + + accumulated_tokens = 0 + cut_index = cut_points[0] + + for index in range(end_index - 1, start_index - 1, -1): + message = _get_message_from_entry(entries[index]) + if message is None: + continue + accumulated_tokens += estimate_tokens(message) + if accumulated_tokens >= keep_recent_tokens: + for cut_point in cut_points: + if cut_point >= index: + cut_index = cut_point + break + break + + while cut_index > start_index: + previous_entry = entries[cut_index - 1] + if str(previous_entry.get("type")) == "compaction": + break + if str(previous_entry.get("type")) == "message": + break + cut_index -= 1 + + cut_entry = entries[cut_index] + is_user_message = _entry_is_user_message(cut_entry) + turn_start_index = -1 if is_user_message else _find_turn_start_index(entries, cut_index, start_index) + return CutPointResult( + first_kept_entry_index=cut_index, + turn_start_index=turn_start_index, + is_split_turn=not is_user_message and turn_start_index != -1, + ) + + +def _generate_summary_text( + *, + model: str, + api_key: str, + base_url: str | None, + timeout: float, + prompt_text: str, + max_tokens: int, +) -> str: + llm = ChatOpenAI( + model=model, + api_key=api_key, + base_url=base_url, + streaming=True, + timeout=timeout, + max_retries=1, + max_tokens=max_tokens, + use_responses_api=False, + ) + messages: list[AnyMessage] = [ + SystemMessage(content=_SUMMARIZATION_SYSTEM_PROMPT), + HumanMessage(content=prompt_text), + ] + chunks: list[object] = [] + for chunk in llm.stream(messages): + chunks.append(chunk) + + if not chunks: + response = llm.invoke(messages) + if isinstance(response, AIMessage): + return _message_text(response).strip() + raise RuntimeError("compaction summarizer returned no content") + + merged = chunks[0] + for chunk in chunks[1:]: + merged = merged + chunk + + if isinstance(merged, AIMessage): + return _message_text(merged).strip() + if hasattr(merged, "to_message"): + converted = merged.to_message() + if isinstance(converted, AIMessage): + return _message_text(converted).strip() + content = getattr(merged, "content", "") + if isinstance(content, str): + return content.strip() + return _extract_text(content).strip() + + +def _get_message_from_entry(entry: dict[str, object]) -> AnyMessage | None: + entry_type = str(entry.get("type")) + if entry_type == "message": + message = cast(dict[str, object], entry.get("message", {})) + return _deserialize_entry_message(message) + if entry_type == "custom_message": + role = str(entry.get("role", "system")) + content = entry.get("content", "") + if role == "user": + return HumanMessage(content=content) + if role == "assistant": + return AIMessage(content=content) + return SystemMessage(content=content) + if entry_type == "branch_summary": + return SystemMessage(content=f"Branch summary:\n{entry.get('summary', '')}") + if entry_type == "compaction": + return SystemMessage(content=f"Compaction summary:\n{entry.get('summary', '')}") + return None + + +def _deserialize_entry_message(payload: dict[str, object]) -> AnyMessage: + role = str(payload.get("role", "")) + content = payload.get("content", "") + if role == "system": + return SystemMessage(content=content) + if role == "user": + return HumanMessage(content=content) + if role == "tool": + return ToolMessage( + content=content, + tool_call_id=str(payload.get("tool_call_id", "")), + name=_optional_str(payload.get("name")), + ) + if role == "assistant": + return AIMessage( + content=content, + tool_calls=cast(list[dict[str, object]], payload.get("tool_calls", [])), + additional_kwargs=cast(dict[str, object], payload.get("additional_kwargs", {})), + response_metadata=cast(dict[str, object], payload.get("response_metadata", {})), + id=_optional_str(payload.get("message_id")), + name=_optional_str(payload.get("name")), + ) + raise ValueError(f"unsupported message role: {role}") + + +def _extract_file_operations( + *, + messages: list[AnyMessage], + entries: list[dict[str, object]], + prev_compaction_index: int, +) -> FileOperations: + file_ops = create_file_ops() + if prev_compaction_index >= 0: + details = entries[prev_compaction_index].get("details") + if isinstance(details, dict): + read_files = details.get("readFiles") + modified_files = details.get("modifiedFiles") + if isinstance(read_files, list): + for path in read_files: + if isinstance(path, str): + file_ops.read.add(path) + if isinstance(modified_files, list): + for path in modified_files: + if isinstance(path, str): + file_ops.edited.add(path) + + for message in messages: + extract_file_ops_from_message(message, file_ops) + return file_ops + + +def _find_previous_compaction_index(entries: list[dict[str, object]]) -> int: + for index in range(len(entries) - 1, -1, -1): + if str(entries[index].get("type")) == "compaction": + return index + return -1 + + +def _skip_leading_system_messages(entries: list[dict[str, object]], start_index: int) -> int: + index = start_index + while index < len(entries): + entry = entries[index] + if str(entry.get("type")) != "message": + break + message = cast(dict[str, object], entry.get("message", {})) + if str(message.get("role")) != "system": + break + index += 1 + return index + + +def _find_valid_cut_points(entries: list[dict[str, object]], start_index: int, end_index: int) -> list[int]: + cut_points: list[int] = [] + for index in range(start_index, end_index): + entry = entries[index] + entry_type = str(entry.get("type")) + if entry_type == "message": + role = str(cast(dict[str, object], entry.get("message", {})).get("role")) + if role in {"user", "assistant"}: + cut_points.append(index) + continue + if entry_type in {"custom_message", "branch_summary"}: + cut_points.append(index) + return cut_points + + +def _find_turn_start_index(entries: list[dict[str, object]], entry_index: int, start_index: int) -> int: + for index in range(entry_index, start_index - 1, -1): + entry = entries[index] + entry_type = str(entry.get("type")) + if entry_type in {"custom_message", "branch_summary"}: + return index + if entry_type == "message": + role = str(cast(dict[str, object], entry.get("message", {})).get("role")) + if role == "user": + return index + return -1 + + +def _entry_is_user_message(entry: dict[str, object]) -> bool: + if str(entry.get("type")) != "message": + return False + return str(cast(dict[str, object], entry.get("message", {})).get("role")) == "user" + + +def _get_last_assistant_usage_info(messages: list[AnyMessage]) -> tuple[int, int] | None: + for index in range(len(messages) - 1, -1, -1): + message = messages[index] + if not isinstance(message, AIMessage): + continue + usage_tokens = _assistant_usage_tokens(message) + if usage_tokens is not None: + return usage_tokens, index + return None + + +def _assistant_usage_tokens(message: AIMessage) -> int | None: + usage_metadata = getattr(message, "usage_metadata", None) + if isinstance(usage_metadata, dict): + total = usage_metadata.get("total_tokens") + if isinstance(total, int) and total > 0: + return total + input_tokens = usage_metadata.get("input_tokens") + output_tokens = usage_metadata.get("output_tokens") + if isinstance(input_tokens, int) and isinstance(output_tokens, int): + return input_tokens + output_tokens + + response_metadata = message.response_metadata + if isinstance(response_metadata, dict): + for key in ("token_usage", "usage"): + usage = response_metadata.get(key) + if not isinstance(usage, dict): + continue + total = usage.get("total_tokens") + if isinstance(total, int) and total > 0: + return total + prompt_tokens = usage.get("prompt_tokens") + completion_tokens = usage.get("completion_tokens") + if isinstance(prompt_tokens, int) and isinstance(completion_tokens, int): + return prompt_tokens + completion_tokens + return None + + +def _serialize_tool_calls(message: AIMessage) -> list[str]: + rendered: list[str] = [] + for tool_call in cast(list[dict[str, object]], message.tool_calls or []): + name = str(tool_call.get("name") or "") + args = tool_call.get("args", {}) + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {"raw": args} + if isinstance(args, dict): + args_str = ", ".join(f"{key}={json.dumps(value, ensure_ascii=False)}" for key, value in args.items()) + else: + args_str = json.dumps(args, ensure_ascii=False) + rendered.append(f"{name}({args_str})") + return rendered + + +def _message_text(message: AnyMessage) -> str: + if isinstance(message.content, str): + return message.content + return _extract_text(message.content) + + +def _extract_text(content: object) -> str: + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + + parts: list[str] = [] + for block in content: + parts.append(_extract_text_content_block(block)) + return "".join(part for part in parts if part) + + +def _iter_message_blocks(content: object) -> list[object]: + if isinstance(content, list): + return content + if content is None: + return [] + return [content] + + +def _extract_text_content_block(block: object) -> str: + if isinstance(block, str): + return block + if isinstance(block, dict): + text = block.get("text") + if isinstance(text, str): + return text + nested = block.get("content") + if isinstance(nested, str): + return nested + if isinstance(nested, list): + return _extract_text(nested) + return "" + text_attr = getattr(block, "text", None) + if isinstance(text_attr, str): + return text_attr + content_attr = getattr(block, "content", None) + if isinstance(content_attr, str): + return content_attr + return "" + + +def _truncate_for_summary(text: str, max_chars: int) -> str: + if len(text) <= max_chars: + return text + truncated_chars = len(text) - max_chars + return f"{text[:max_chars]}\n\n[... {truncated_chars} more characters truncated]" + + +def _optional_str(value: object) -> str | None: + return value if isinstance(value, str) else None + + +def _env_int(name: str, default: int) -> int: + value = os.getenv(name) + if value is None: + return default + try: + return int(value) + except ValueError: + return default + + +def _env_bool(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() not in {"0", "false", "no", "off"} diff --git a/app/agentland-agent/app/services/ralph_service.py b/app/agentland-agent/app/services/ralph_service.py new file mode 100644 index 0000000..16867c4 --- /dev/null +++ b/app/agentland-agent/app/services/ralph_service.py @@ -0,0 +1,1248 @@ +from __future__ import annotations + +"""Ralph-style orchestration built on top of the existing agent loop.""" + +import asyncio +import json +import os +import re +import threading +import uuid +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +from fastapi import HTTPException +from fastapi.responses import StreamingResponse +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI + +from app.models.ralph import RalphPrd, RalphRunLock, RalphRunState +from app.schemas.chat import DEFAULT_SYSTEM_PROMPT +from app.schemas.ralph import RalphStreamRequest +from app.services.agent_loop import AgentConfig, Hooks, run_agent +from app.services.session_memory import SessionManager +from app.services.skills_service import build_skill_aware_system_prompt +from app.services.tools import load_tools, tool_signature +_run_locks: dict[str, RalphRunLock] = {} +_run_locks_guard = threading.Lock() +_model_cache: dict[tuple[str, str | None, float, tuple[str, ...]], object] = {} +_model_cache_lock = threading.Lock() + +_COMPLETE_MARKER = "COMPLETE" +type EventEmitter = Callable[[str, dict[str, object]], None] + + +@dataclass(slots=True) +class _PlanningResult: + """Initial planning output for a Ralph run.""" + + prd: RalphPrd + fallback_reason: str | None = None + + +@dataclass(slots=True) +class _WorkspaceSnapshot: + """Workspace state captured around a Ralph iteration.""" + + progress_text: str + file_signatures: dict[str, tuple[int, int]] + + +@dataclass(slots=True, frozen=True) +class _RunCandidate: + """An existing Ralph run that can be resumed or skipped.""" + + run_id: str + run_dir: Path + prd_path: Path + progress_path: Path + order: int + complete: bool + + +async def stream_ralph(request: RalphStreamRequest) -> StreamingResponse: + """Run a Ralph-compatible outer loop and stream lifecycle events.""" + + async_queue: asyncio.Queue[dict[str, object] | None] = asyncio.Queue() + loop = asyncio.get_running_loop() + + def emit(event: str, data: dict[str, object]) -> None: + loop.call_soon_threadsafe(async_queue.put_nowait, {"event": event, "data": data}) + + def worker() -> None: + try: + run_ralph(request=request, emit=emit) + except HTTPException as exc: + emit("error", {"status_code": exc.status_code, "message": exc.detail}) + except Exception as exc: # noqa: BLE001 + emit("error", {"message": str(exc)}) + finally: + loop.call_soon_threadsafe(async_queue.put_nowait, None) + + threading.Thread(target=worker, daemon=True).start() + + async def event_stream(): + while True: + try: + item = await asyncio.wait_for(async_queue.get(), timeout=15.0) + except TimeoutError: + yield _sse("ping", {"ts": int(asyncio.get_event_loop().time())}) + continue + if item is None: + break + yield _sse(str(item["event"]), item["data"]) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +def run_ralph(*, request: RalphStreamRequest, emit: EventEmitter) -> None: + """同步执行 Ralph loop,并通过 emitter 输出事件。""" + + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise HTTPException(status_code=500, detail="OPENAI_API_KEY not set") + if request.iterations <= 0: + raise HTTPException(status_code=400, detail="iterations must be > 0") + if request.agent_max_turns <= 0: + raise HTTPException(status_code=400, detail="agent_max_turns must be > 0") + + session_id = request.session_id or f"ralph-{uuid.uuid4().hex[:8]}" + run_lock = _get_or_create_run_lock(session_id) + + with run_lock.lock: + if run_lock.running: + raise HTTPException(status_code=409, detail="ralph session is already running") + run_lock.running = True + + try: + run_state = _build_run_state(session_id=session_id, workspace_path=request.workspace_path) + session_manager = SessionManager.open_or_create( + cwd=run_state.workspace_path, + session_id=session_id, + system_prompt=DEFAULT_SYSTEM_PROMPT, + ) + planner_memory = _render_memory_for_planner(session_manager.build_session_context()) + is_new_run = not run_state.prd_path.exists() + if is_new_run: + session_manager.append_message(HumanMessage(content=request.requirement)) + planning = _ensure_run_files( + request=request, + run_state=run_state, + api_key=api_key, + planner_memory=planner_memory, + ) + prd = planning.prd + emit( + "session", + { + "session_id": session_id, + "workspace_path": str(run_state.workspace_path), + "session_root": str(run_state.session_root), + "run_id": run_state.run_id, + "run_dir": str(run_state.run_dir), + "prd_path": str(run_state.prd_path), + "progress_path": str(run_state.progress_path), + }, + ) + if planning.fallback_reason is not None: + emit( + "planner_fallback", + { + "reason": planning.fallback_reason, + "mode": "single_story_fallback", + }, + ) + emit( + "plan_ready", + { + "branch_name": prd.branchName, + "stories": [ + { + "id": story.id, + "title": story.title, + "priority": story.priority, + "passes": story.passes, + } + for story in prd.sorted_stories() + ], + }, + ) + tools = load_tools() + + model = _call_get_bound_model( + api_key=api_key, + model=request.model, + base_url=request.base_url, + timeout=request.timeout, + tools=tools, + ) + + for iteration in range(1, request.iterations + 1): + prd_before_iteration = _read_prd(run_state.prd_path) + workspace_before_iteration = _snapshot_workspace(run_state) + emit( + "iteration_start", + { + "iteration": iteration, + "max_iterations": request.iterations, + }, + ) + stream_state = {"has_delta": False} + + def emit_assistant_delta(text: str) -> None: + if not text: + return + stream_state["has_delta"] = True + emit( + "assistant_delta", + { + "iteration": iteration, + "content": text, + }, + ) + + history = [ + SystemMessage(content=build_skill_aware_system_prompt(_build_iteration_system_prompt(run_state), run_state.workspace_path)), + HumanMessage( + content=_build_iteration_user_prompt( + request=request, + run_state=run_state, + iteration=iteration, + max_iterations=request.iterations, + ) + ), + ] + out = run_agent( + history, + AgentConfig( + model=model, + tools=tools, + max_turns=request.agent_max_turns, + hooks=Hooks( + on_assistant_delta=emit_assistant_delta, + on_assistant=lambda message: _emit_assistant_fallback( + emit_assistant_delta, + message, + stream_state, + ), + on_tool_call=lambda call: emit( + "tool_call", + { + "iteration": iteration, + "id": call.get("id", ""), + "name": call.get("name", ""), + "args": call.get("args", {}), + }, + ), + on_tool_result=lambda message: emit( + "tool_result", + { + "iteration": iteration, + "tool_call_id": message.tool_call_id or "", + "name": message.name or "", + "content": _normalize_tool_content(message.content), + }, + ), + ), + ), + ) + + final_message = _find_last_ai_message(out) + final_text = _extract_text_content(final_message.content) if final_message is not None else "" + prd = _reconcile_prd_with_workspace( + run_state=run_state, + previous_snapshot=workspace_before_iteration, + ) + run_complete = all(story.passes for story in prd.userStories) + emit( + "iteration_complete", + { + "iteration": iteration, + "complete": run_complete, + }, + ) + if run_complete: + emit( + "done", + { + "session_id": session_id, + "status": "complete", + "iteration": iteration, + }, + ) + _append_ralph_result_to_memory( + session_manager=session_manager, + request=request, + run_state=run_state, + status="complete", + iteration=iteration, + ) + return + if _COMPLETE_MARKER in final_text and any(not story.passes for story in prd_before_iteration.userStories): + emit( + "planner_fallback", + { + "reason": "agent emitted COMPLETE before all stories passed; continuing iterations", + "mode": "completion_guard", + }, + ) + + emit( + "done", + { + "session_id": session_id, + "status": "max_iterations_reached", + "iteration": request.iterations, + }, + ) + _append_ralph_result_to_memory( + session_manager=session_manager, + request=request, + run_state=run_state, + status="max_iterations_reached", + iteration=request.iterations, + ) + finally: + with run_lock.lock: + run_lock.running = False + + +def _get_or_create_run_lock(session_id: str) -> RalphRunLock: + with _run_locks_guard: + run_lock = _run_locks.get(session_id) + if run_lock is None: + run_lock = RalphRunLock(session_id) + _run_locks[session_id] = run_lock + return run_lock + + +def _build_run_state(session_id: str, workspace_path: str | None) -> RalphRunState: + workspace = _resolve_workspace_path(workspace_path) + session_root = workspace / ".ralph" / session_id + candidate = _select_resumable_run(session_root) + if candidate is not None: + run_id = candidate.run_id + run_dir = candidate.run_dir + else: + run_id = _next_run_id(session_root) + run_dir = session_root / run_id + + return RalphRunState( + session_id=session_id, + run_id=run_id, + workspace_path=workspace, + session_root=session_root, + run_dir=run_dir, + prd_path=run_dir / "prd.json", + progress_path=run_dir / "progress.txt", + ) + + +def _resolve_workspace_path(workspace_path: str | None) -> Path: + workspace = Path(workspace_path or os.getcwd()).expanduser().resolve() + if not workspace.exists(): + raise HTTPException(status_code=400, detail=f"workspace does not exist: {workspace}") + if not workspace.is_dir(): + raise HTTPException(status_code=400, detail=f"workspace is not a directory: {workspace}") + return workspace + + +def _select_resumable_run(session_root: Path) -> _RunCandidate | None: + candidates = _list_run_candidates(session_root) + unfinished = [candidate for candidate in candidates if not candidate.complete] + if unfinished: + return unfinished[-1] + return None + + +def _next_run_id(session_root: Path) -> str: + next_index = 1 + for candidate in _list_run_candidates(session_root): + if candidate.run_id == "legacy": + continue + try: + next_index = max(next_index, int(candidate.run_id.removeprefix("run-")) + 1) + except ValueError: + continue + return f"run-{next_index:04d}" + + +def _list_run_candidates(session_root: Path) -> list[_RunCandidate]: + candidates: list[_RunCandidate] = [] + legacy_candidate = _candidate_from_run_dir(run_dir=session_root, run_id="legacy", order=0) + if legacy_candidate is not None: + candidates.append(legacy_candidate) + + if not session_root.exists(): + return candidates + + for run_dir in sorted( + ( + path + for path in session_root.iterdir() + if path.is_dir() and re.fullmatch(r"run-\d{4}", path.name) + ), + key=lambda path: path.name, + ): + run_id = run_dir.name + try: + order = int(run_id.removeprefix("run-")) + except ValueError: + continue + candidate = _candidate_from_run_dir(run_dir=run_dir, run_id=run_id, order=order) + if candidate is not None: + candidates.append(candidate) + return sorted(candidates, key=lambda candidate: candidate.order) + + +def _candidate_from_run_dir(*, run_dir: Path, run_id: str, order: int) -> _RunCandidate | None: + prd_path = run_dir / "prd.json" + if not prd_path.exists(): + return None + + try: + prd = _read_prd(prd_path) + except Exception: # noqa: BLE001 + return None + + return _RunCandidate( + run_id=run_id, + run_dir=run_dir, + prd_path=prd_path, + progress_path=run_dir / "progress.txt", + order=order, + complete=all(story.passes for story in prd.userStories), + ) + + +def _ensure_run_files( + *, + request: RalphStreamRequest, + run_state: RalphRunState, + api_key: str, + planner_memory: str, +) -> _PlanningResult: + run_state.run_dir.mkdir(parents=True, exist_ok=True) + if run_state.prd_path.exists(): + return _PlanningResult( + prd=RalphPrd.model_validate_json(run_state.prd_path.read_text(encoding="utf-8")) + ) + + planning = _coerce_planning_result( + _call_plan_prd_from_requirement( + request=request, + run_state=run_state, + api_key=api_key, + planner_memory=planner_memory, + ) + ) + run_state.prd_path.write_text( + json.dumps(planning.prd.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + if not run_state.progress_path.exists(): + run_state.progress_path.write_text( + "# Ralph Progress Log\n" + f"Started: {datetime.now().isoformat(timespec='seconds')}\n" + "---\n", + encoding="utf-8", + ) + return planning + + +def _read_prd(prd_path: Path) -> RalphPrd: + return RalphPrd.model_validate_json(prd_path.read_text(encoding="utf-8")) + + +def _write_prd(prd_path: Path, prd: RalphPrd) -> None: + prd_path.write_text( + json.dumps(prd.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _coerce_planning_result(result: _PlanningResult | RalphPrd) -> _PlanningResult: + """兼容旧测试桩:允许直接返回 RalphPrd。""" + + if isinstance(result, _PlanningResult): + return result + if isinstance(result, RalphPrd): + return _PlanningResult(prd=result) + raise TypeError(f"unexpected planning result: {type(result)!r}") + + +def _snapshot_workspace(run_state: RalphRunState) -> _WorkspaceSnapshot: + progress_text = "" + if run_state.progress_path.exists(): + progress_text = run_state.progress_path.read_text(encoding="utf-8") + + file_signatures: dict[str, tuple[int, int]] = {} + for file_path in run_state.workspace_path.rglob("*"): + if not file_path.is_file(): + continue + relative_path = file_path.relative_to(run_state.workspace_path) + if _should_ignore_snapshot_path(relative_path): + continue + stat = file_path.stat() + file_signatures[relative_path.as_posix()] = (stat.st_size, stat.st_mtime_ns) + + return _WorkspaceSnapshot(progress_text=progress_text, file_signatures=file_signatures) + + +def _should_ignore_snapshot_path(relative_path: Path) -> bool: + parts = relative_path.parts + return bool(parts) and parts[0] in {".git", ".ralph"} + + +def _reconcile_prd_with_workspace( + *, + run_state: RalphRunState, + previous_snapshot: _WorkspaceSnapshot, +) -> RalphPrd: + prd = _read_prd(run_state.prd_path) + current_snapshot = _snapshot_workspace(run_state) + changed_paths = _changed_workspace_paths(previous_snapshot, current_snapshot) + + updated = False + for story in prd.userStories: + if story.passes: + continue + if _story_matches_workspace( + story=story, + run_state=run_state, + previous_snapshot=previous_snapshot, + current_snapshot=current_snapshot, + changed_paths=changed_paths, + ): + story.passes = True + updated = True + + if updated: + _write_prd(run_state.prd_path, prd) + return prd + + +def _changed_workspace_paths( + previous_snapshot: _WorkspaceSnapshot, + current_snapshot: _WorkspaceSnapshot, +) -> set[str]: + changed: set[str] = set() + all_paths = set(previous_snapshot.file_signatures) | set(current_snapshot.file_signatures) + for path in all_paths: + if previous_snapshot.file_signatures.get(path) != current_snapshot.file_signatures.get(path): + changed.add(path) + return changed + + +def _story_matches_workspace( + *, + story: object, + run_state: RalphRunState, + previous_snapshot: _WorkspaceSnapshot, + current_snapshot: _WorkspaceSnapshot, + changed_paths: set[str], +) -> bool: + title = getattr(story, "title", "") + description = getattr(story, "description", "") + acceptance = getattr(story, "acceptanceCriteria", []) + story_text = "\n".join([str(title), str(description), *(str(item) for item in acceptance)]) + story_text_lower = story_text.lower() + + criteria = [str(item) for item in acceptance] + if criteria and all( + _criterion_matches_workspace( + criterion=criterion, + story_text=story_text, + story_text_lower=story_text_lower, + run_state=run_state, + previous_snapshot=previous_snapshot, + current_snapshot=current_snapshot, + changed_paths=changed_paths, + ) + for criterion in criteria + ): + return True + + if _mentions_progress_log(story_text_lower) and _progress_log_was_updated(previous_snapshot, current_snapshot): + return True + + skill_names = _extract_skill_names(story_text_lower) + if skill_names and any(_skill_artifact_exists(run_state, skill_name, changed_paths) for skill_name in skill_names): + return True + + file_names = _extract_file_names(story_text) + if file_names and all((run_state.workspace_path / file_name).is_file() for file_name in file_names): + return True + + return False + + +def _criterion_matches_workspace( + *, + criterion: str, + story_text: str, + story_text_lower: str, + run_state: RalphRunState, + previous_snapshot: _WorkspaceSnapshot, + current_snapshot: _WorkspaceSnapshot, + changed_paths: set[str], +) -> bool: + criterion_lower = criterion.lower() + file_names = _extract_file_names(f"{story_text}\n{criterion}") + + if "file named" in criterion_lower and file_names: + return all((run_state.workspace_path / file_name).is_file() for file_name in file_names) + + if "content equals exactly:" in criterion_lower and file_names: + expected_content = _extract_expected_content(criterion) + if expected_content is None: + return False + file_path = run_state.workspace_path / file_names[0] + if not file_path.is_file(): + return False + actual = file_path.read_text(encoding="utf-8") + return actual == expected_content or actual == f"{expected_content}\n" + + if _mentions_progress_log(criterion_lower): + if not _progress_log_was_updated(previous_snapshot, current_snapshot): + return False + if "contains" in criterion_lower: + return _progress_mentions_expected_context( + criterion=criterion_lower, + story_text_lower=story_text_lower, + progress_text=current_snapshot.progress_text.lower(), + ) + if "append" in criterion_lower: + return _progress_entry_count(current_snapshot.progress_text) > _progress_entry_count(previous_snapshot.progress_text) + return bool(current_snapshot.progress_text.strip()) + + skill_names = _extract_skill_names(criterion_lower or story_text_lower) + if skill_names and "skill" in criterion_lower: + return any(_skill_artifact_exists(run_state, skill_name, changed_paths) for skill_name in skill_names) + + if file_names and any(file_name in changed_paths for file_name in file_names): + return True + + return False + + +def _mentions_progress_log(text: str) -> bool: + return "progress log" in text or "progress.txt" in text + + +def _progress_log_was_updated( + previous_snapshot: _WorkspaceSnapshot, + current_snapshot: _WorkspaceSnapshot, +) -> bool: + return current_snapshot.progress_text != previous_snapshot.progress_text + + +def _progress_entry_count(progress_text: str) -> int: + return progress_text.count("\n## ") + + +def _progress_mentions_expected_context( + *, + criterion: str, + story_text_lower: str, + progress_text: str, +) -> bool: + tokens = sorted( + { + *[name.lower() for name in _extract_file_names(f"{criterion}\n{story_text_lower}")], + *[name.lower() for name in _extract_skill_names(f"{criterion}\n{story_text_lower}")], + } + ) + if not tokens: + return bool(progress_text.strip()) + return all(token in progress_text for token in tokens) + + +def _skill_artifact_exists( + run_state: RalphRunState, + skill_name: str, + changed_paths: set[str], +) -> bool: + skill_root = run_state.workspace_path / ".deepagents" / "skills" / skill_name + if not skill_root.exists(): + return False + + for file_path in skill_root.rglob("*"): + if not file_path.is_file(): + continue + if file_path.name == "SKILL.md": + continue + relative = file_path.relative_to(run_state.workspace_path).as_posix() + if relative in changed_paths or file_path.exists(): + return True + return False + + +def _extract_file_names(text: str) -> list[str]: + return list(dict.fromkeys(re.findall(r"\b[\w./-]+\.[A-Za-z0-9_-]+\b", text))) + + +def _extract_skill_names(text: str) -> list[str]: + matches = re.findall(r"\b([a-z0-9][a-z0-9-]*) skill\b", text) + return list(dict.fromkeys(matches)) + + +def _extract_expected_content(criterion: str) -> str | None: + match = re.search(r'content equals exactly:\s*"([^"]*)"', criterion, flags=re.IGNORECASE) + if match is None: + return None + return match.group(1) + + +def _plan_prd_from_requirement( + *, + request: RalphStreamRequest, + run_state: RalphRunState, + api_key: str, + planner_memory: str = "", +) -> _PlanningResult: + project_name = request.project_name or run_state.workspace_path.name + try: + return _PlanningResult( + prd=_call_planner( + request=request, + run_state=run_state, + api_key=api_key, + project_name=project_name, + planner_memory=planner_memory, + ) + ) + except Exception as exc: # noqa: BLE001 + return _PlanningResult( + prd=_build_fallback_prd( + requirement=request.requirement, + project_name=project_name, + ), + fallback_reason=f"{type(exc).__name__}: {exc}", + ) + + +def _call_plan_prd_from_requirement( + *, + request: RalphStreamRequest, + run_state: RalphRunState, + api_key: str, + planner_memory: str, +) -> _PlanningResult | RalphPrd: + """兼容旧测试桩:planner_memory 参数缺失时回退到旧签名。""" + + try: + return _plan_prd_from_requirement( + request=request, + run_state=run_state, + api_key=api_key, + planner_memory=planner_memory, + ) + except TypeError as exc: + if "planner_memory" not in str(exc): + raise + return _plan_prd_from_requirement( + request=request, + run_state=run_state, + api_key=api_key, + ) + + +def _call_planner( + *, + request: RalphStreamRequest, + run_state: RalphRunState, + api_key: str, + project_name: str, + planner_memory: str, +) -> RalphPrd: + """兼容旧测试桩:当 monkeypatch 未声明 `project_name` 时回退到旧签名。""" + + try: + return _plan_prd_with_model( + request=request, + run_state=run_state, + api_key=api_key, + project_name=project_name, + planner_memory=planner_memory, + ) + except TypeError as exc: + message = str(exc) + if "project_name" not in message and "planner_memory" not in message: + raise + return _plan_prd_with_model( + request=request, + run_state=run_state, + api_key=api_key, + ) + + +def _plan_prd_with_model( + *, + request: RalphStreamRequest, + run_state: RalphRunState, + api_key: str, + project_name: str | None = None, + planner_memory: str = "", +) -> RalphPrd: + effective_project_name = project_name or run_state.workspace_path.name + planner = ChatOpenAI( + model=request.model, + api_key=api_key, + base_url=request.base_url, + streaming=True, + timeout=request.timeout, + max_retries=1, + use_responses_api=False, + ) + messages = [ + SystemMessage(content=_build_planner_system_prompt()), + HumanMessage( + content=( + f"Project name: {effective_project_name}\n" + f"Workspace: {run_state.workspace_path}\n" + "Prior memory from the persistent session file:\n" + f"{planner_memory or '(none)'}\n\n" + f"Requirement:\n{request.requirement}\n" + ) + ), + ] + response = _collect_streamed_ai_message(planner, messages) + content = _extract_text_content(response.content) + raw = _extract_json_object(content) + document = json.loads(raw) + normalized = _normalize_prd(document, project_name=effective_project_name) + return RalphPrd.model_validate(normalized) + + +def _build_fallback_prd(*, requirement: str, project_name: str) -> RalphPrd: + description = requirement.strip() or "Implement requested change" + return RalphPrd.model_validate( + { + "project": project_name, + "branchName": f"ralph/{_slugify(description)}", + "description": description, + "userStories": [ + { + "id": "US-001", + "title": "Implement requested requirement", + "description": description, + "acceptanceCriteria": [ + "Implement the requested change in the target workspace.", + "Run the relevant quality checks for the affected code.", + "If checks pass, update progress.txt and mark the story as passed.", + ], + "priority": 1, + "passes": False, + "notes": "Fallback PRD generated because automatic planning failed.", + } + ], + } + ) + + +def _normalize_prd(document: dict[str, object], *, project_name: str) -> dict[str, object]: + description = str(document.get("description") or "").strip() + if not description: + raise ValueError("planner did not return a description") + + branch_name = str(document.get("branchName") or "").strip() + if not branch_name: + branch_name = f"ralph/{_slugify(description)}" + elif not branch_name.startswith("ralph/"): + branch_name = f"ralph/{_slugify(branch_name)}" + + raw_stories = document.get("userStories") + if not isinstance(raw_stories, list) or not raw_stories: + raise ValueError("planner did not return any user stories") + + user_stories: list[dict[str, object]] = [] + for index, item in enumerate(raw_stories, start=1): + if not isinstance(item, dict): + raise ValueError("planner returned an invalid user story") + acceptance = item.get("acceptanceCriteria") + acceptance_list = acceptance if isinstance(acceptance, list) else [] + cleaned_acceptance = [str(entry).strip() for entry in acceptance_list if str(entry).strip()] + if not cleaned_acceptance: + raise ValueError("planner returned a story without acceptance criteria") + user_stories.append( + { + "id": str(item.get("id") or f"US-{index:03d}").strip(), + "title": str(item.get("title") or "").strip(), + "description": str(item.get("description") or "").strip(), + "acceptanceCriteria": cleaned_acceptance, + "priority": int(item.get("priority") or index), + "passes": bool(item.get("passes", False)), + "notes": str(item.get("notes") or ""), + } + ) + + return { + "project": str(document.get("project") or project_name).strip(), + "branchName": branch_name, + "description": description, + "userStories": user_stories, + } + + +def _build_planner_system_prompt() -> str: + return ( + "Convert the requirement into a Ralph-compatible JSON PRD.\n" + "Use the prior memory to preserve ongoing user intent and project context.\n" + "If the prior memory conflicts with the newest requirement, prefer the newest requirement.\n" + "Return JSON only, with this exact top-level shape:\n" + "{" + '"project": string, ' + '"branchName": string, ' + '"description": string, ' + '"userStories": [' + "{" + '"id": string, ' + '"title": string, ' + '"description": string, ' + '"acceptanceCriteria": [string], ' + '"priority": integer, ' + '"passes": false, ' + '"notes": string' + "}" + "]" + "}\n" + "Rules:\n" + "- Produce 1 to 5 user stories.\n" + "- Each story must be small enough to complete in one iteration.\n" + "- Priorities must start at 1 and increase without gaps.\n" + "- branchName must start with 'ralph/'.\n" + "- Keep acceptance criteria concrete and verifiable.\n" + "- Set passes to false for every story.\n" + ) + + +def _render_memory_for_planner(messages: list[object]) -> str: + lines: list[str] = [] + for message in messages: + if isinstance(message, SystemMessage): + content = _extract_text_content(message.content) + if not content or content == DEFAULT_SYSTEM_PROMPT: + continue + lines.append(f"system: {content}") + continue + if isinstance(message, HumanMessage): + content = _extract_text_content(message.content) + if content: + lines.append(f"user: {content}") + continue + if isinstance(message, AIMessage): + content = _extract_text_content(message.content) + if content: + lines.append(f"assistant: {content}") + return "\n".join(lines).strip() + + +def _append_ralph_result_to_memory( + *, + session_manager: SessionManager, + request: RalphStreamRequest, + run_state: RalphRunState, + status: str, + iteration: int, +) -> None: + prd = _read_prd(run_state.prd_path) + completed = [story.id for story in prd.sorted_stories() if story.passes] + remaining = [story.id for story in prd.sorted_stories() if not story.passes] + progress_summary = _read_last_progress_entry(run_state.progress_path) + summary = ( + "Persistent orchestrator memory\n" + "The following Ralph task result was recorded by the system after checking workspace state.\n" + "Treat it as factual session memory about completed work, not as a hypothetical plan.\n\n" + "Ralph task result\n" + f"Requirement: {request.requirement}\n" + f"Status: {status}\n" + f"Iterations used: {iteration}\n" + f"Completed stories: {', '.join(completed) if completed else '(none)'}\n" + f"Remaining stories: {', '.join(remaining) if remaining else '(none)'}\n" + f"Run id: {run_state.run_id}\n" + f"Run directory: {run_state.run_dir}\n" + f"Latest progress entry:\n{progress_summary}" + ) + session_manager.append_custom_entry( + custom_type="ralph_result", + data={ + "requirement": request.requirement, + "status": status, + "iteration": iteration, + "completed_story_ids": completed, + "remaining_story_ids": remaining, + "run_id": run_state.run_id, + "run_dir": str(run_state.run_dir), + "progress_path": str(run_state.progress_path), + "prd_path": str(run_state.prd_path), + }, + ) + session_manager.append_custom_message_entry( + custom_type="ralph_result_memory", + content=summary, + display=False, + role="system", + ) + + +def _read_last_progress_entry(progress_path: Path) -> str: + if not progress_path.exists(): + return "(progress log not found)" + + progress_text = progress_path.read_text(encoding="utf-8").strip() + if not progress_text: + return "(progress log is empty)" + + parts = [part.strip() for part in progress_text.split("\n---") if part.strip()] + return parts[-1] if parts else progress_text + + +def _build_iteration_system_prompt(run_state: RalphRunState) -> str: + return ( + "You are an autonomous coding agent working on a software project.\n\n" + "Your durable memory between iterations is outside the chat context and " + "lives in:\n" + f"- Git history in the workspace {run_state.workspace_path}\n" + f"- PRD file: {run_state.prd_path}\n" + f"- Progress log: {run_state.progress_path}\n\n" + "Your task on each iteration:\n" + f"1. Read the PRD at {run_state.prd_path}.\n" + f"2. Read the progress log at {run_state.progress_path} and check the " + "Codebase Patterns section first if it exists.\n" + "3. Check you are on the correct branch from PRD branchName. If not, " + "check it out or create it from main.\n" + "4. Pick the highest priority user story where passes is false.\n" + "5. Implement only that single story.\n" + "6. Run quality checks appropriate for the project.\n" + "7. Update nearby AGENTS.md files if you discover reusable patterns.\n" + "8. If checks pass, commit changes with message: " + "`feat: [Story ID] - [Story Title]`.\n" + "9. Update the PRD to set passes to true for the completed story.\n" + "10. Append progress to progress.txt.\n\n" + "Progress report format in progress.txt:\n" + "## [Date/Time] - [Story ID]\n" + "- What was implemented\n" + "- Files changed\n" + "- Learnings for future iterations:\n" + " - Patterns discovered\n" + " - Gotchas encountered\n" + " - Useful context\n" + "---\n\n" + "Important rules:\n" + "- Work on one story per iteration.\n" + "- Use absolute file paths for read and write tools.\n" + f"- Use bash with cwd set to {run_state.workspace_path} when operating " + "in the repo.\n" + "- Do not rely on previous chat history. It will be discarded.\n" + f"- If all stories pass, end your final response with exactly " + f"{_COMPLETE_MARKER}.\n" + ) + + +def _build_iteration_user_prompt( + *, + request: RalphStreamRequest, + run_state: RalphRunState, + iteration: int, + max_iterations: int, +) -> str: + return ( + f"Ralph iteration {iteration} of {max_iterations}.\n" + f"Workspace path: {run_state.workspace_path}\n" + f"Run directory: {run_state.run_dir}\n" + f"PRD path: {run_state.prd_path}\n" + f"Progress path: {run_state.progress_path}\n" + "Start by reading the PRD and progress files.\n" + "Requirement summary for this run:\n" + f"{request.requirement}\n" + ) + + +def _extract_json_object(text: str) -> str: + if not text.strip(): + raise ValueError("planner returned empty content") + + fenced = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, flags=re.DOTALL) + if fenced is not None: + return fenced.group(1) + + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end <= start: + raise ValueError("planner did not return JSON") + return text[start : end + 1] + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or "task" + + +def _get_bound_model(*, api_key: str, model: str, base_url: str | None, timeout: float, tools: list[object]): + key = (model, base_url, timeout, tool_signature(tools)) + with _model_cache_lock: + bound = _model_cache.get(key) + if bound is not None: + return bound + + llm = ChatOpenAI( + model=model, + api_key=api_key, + base_url=base_url, + streaming=True, + timeout=timeout, + max_retries=1, + stream_usage=False, + use_responses_api=False, + ) + bound = llm.bind_tools(tools) + _model_cache[key] = bound + return bound + + +def _call_get_bound_model( + *, + api_key: str, + model: str, + base_url: str | None, + timeout: float, + tools: list[object], +): + """兼容旧测试桩:tools 参数不可用时回退到旧签名。""" + + try: + return _get_bound_model( + api_key=api_key, + model=model, + base_url=base_url, + timeout=timeout, + tools=tools, + ) + except TypeError as exc: + if "tools" not in str(exc): + raise + return _get_bound_model( + api_key=api_key, + model=model, + base_url=base_url, + timeout=timeout, + ) + + +def _collect_streamed_ai_message(model: object, messages: list[object]) -> AIMessage: + """Collect a streamed model response into a single AIMessage.""" + + chunks: list[object] = [] + for chunk in model.stream(messages): + chunks.append(chunk) + + if not chunks: + raise RuntimeError("planner stream returned no chunks") + + merged = chunks[0] + for chunk in chunks[1:]: + merged = merged + chunk + + if isinstance(merged, AIMessage): + return merged + + if hasattr(merged, "to_message"): + converted = merged.to_message() + if isinstance(converted, AIMessage): + return converted + + content = getattr(merged, "content", "") + tool_calls = getattr(merged, "tool_calls", []) + additional_kwargs = getattr(merged, "additional_kwargs", {}) + response_metadata = getattr(merged, "response_metadata", {}) + message_id = getattr(merged, "id", None) + return AIMessage( + content=content, + tool_calls=tool_calls, + additional_kwargs=additional_kwargs, + response_metadata=response_metadata, + id=message_id, + ) + + +def _emit_assistant_fallback( + emit_assistant_delta: Callable[[str], None], + message: AIMessage, + stream_state: dict[str, bool], +) -> None: + if stream_state.get("has_delta", False): + return + + content = _extract_text_content(message.content) + if content: + emit_assistant_delta(content) + return + + refusal = message.additional_kwargs.get("refusal") + if isinstance(refusal, str) and refusal: + emit_assistant_delta(refusal) + + +def _find_last_ai_message(messages: list[object]) -> AIMessage | None: + for message in reversed(messages): + if isinstance(message, AIMessage): + return message + return None + + +def _extract_text_content(content: object) -> str: + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + continue + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str): + parts.append(text) + continue + nested = item.get("content") + if isinstance(nested, str): + parts.append(nested) + continue + text_attr = getattr(item, "text", None) + if isinstance(text_attr, str): + parts.append(text_attr) + continue + content_attr = getattr(item, "content", None) + if isinstance(content_attr, str): + parts.append(content_attr) + + return "".join(parts).strip() + + +def _normalize_tool_content(content: object) -> object: + if isinstance(content, list): + parts: list[object] = [] + for item in content: + if isinstance(item, dict): + parts.append(item) + else: + parts.append({"type": "text", "text": str(item)}) + return parts + return content + + +def _sse(event: str, data: dict[str, object]) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" diff --git a/app/agentland-agent/app/services/session_memory.py b/app/agentland-agent/app/services/session_memory.py new file mode 100644 index 0000000..8dcfb98 --- /dev/null +++ b/app/agentland-agent/app/services/session_memory.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +"""pi-mono 风格的 append-only JSONL 会话存储。""" + +import json +import os +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Literal, cast + +from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, SystemMessage, ToolMessage + +type JsonValue = None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] + +SESSION_VERSION = 3 +SESSION_ROOT_ENV = "PI_SESSION_ROOT" +_DEFAULT_SESSION_ROOT = Path.home() / ".pi" / "agent" / "sessions" + + +@dataclass(slots=True) +class SessionHeader: + """会话头信息。""" + + session_id: str + cwd: Path + timestamp: str + version: int = SESSION_VERSION + + +class SessionManager: + """基于 JSONL 的树状会话管理器。""" + + __slots__ = ("_cwd", "_entries", "_header", "_leaf_id", "_ordered_ids", "_session_file") + + def __init__( + self, + *, + header: SessionHeader, + session_file: Path, + entries: dict[str, dict[str, object]], + ordered_ids: list[str], + leaf_id: str | None, + ) -> None: + self._header = header + self._session_file = session_file + self._entries = entries + self._ordered_ids = ordered_ids + self._leaf_id = leaf_id + self._cwd = header.cwd + + @classmethod + def open_or_create( + cls, + *, + cwd: Path, + session_id: str | None, + system_prompt: str, + storage_root: Path | None = None, + ) -> "SessionManager": + """按 cwd/session_id 打开会话;不存在则创建。""" + + resolved_cwd = cwd.expanduser().resolve() + resolved_root = _resolve_session_root(storage_root) + session_dir = resolved_root / _cwd_bucket_name(resolved_cwd) + session_dir.mkdir(parents=True, exist_ok=True) + + target_session_id = session_id or f"session-{uuid.uuid4().hex[:8]}" + existing_file = _find_session_file(session_dir, target_session_id) + if existing_file is None: + manager = cls._create(session_dir=session_dir, cwd=resolved_cwd, session_id=target_session_id) + else: + manager = cls.open(existing_file) + + if not manager._ordered_ids: + manager.append_message(SystemMessage(content=system_prompt)) + return manager + + @classmethod + def open(cls, session_file: Path) -> "SessionManager": + """打开已有会话文件。""" + + lines = session_file.read_text(encoding="utf-8").splitlines() + if not lines: + raise ValueError(f"empty session file: {session_file}") + + raw_header = json.loads(lines[0]) + header = SessionHeader( + session_id=str(raw_header["id"]), + cwd=Path(str(raw_header["cwd"])).expanduser().resolve(), + timestamp=str(raw_header["timestamp"]), + version=int(raw_header.get("version", SESSION_VERSION)), + ) + + entries: dict[str, dict[str, object]] = {} + ordered_ids: list[str] = [] + leaf_id: str | None = None + + for line in lines[1:]: + if not line.strip(): + continue + entry = cast(dict[str, object], json.loads(line)) + entry_id = str(entry["id"]) + entries[entry_id] = entry + ordered_ids.append(entry_id) + leaf_id = entry_id + + return cls( + header=header, + session_file=session_file, + entries=entries, + ordered_ids=ordered_ids, + leaf_id=leaf_id, + ) + + @classmethod + def _create(cls, *, session_dir: Path, cwd: Path, session_id: str) -> "SessionManager": + timestamp = _now_iso() + session_file = session_dir / f"{_timestamp_slug(timestamp)}_{session_id}.jsonl" + header = SessionHeader(session_id=session_id, cwd=cwd, timestamp=timestamp) + session_file.write_text( + json.dumps( + { + "type": "session", + "version": header.version, + "id": header.session_id, + "timestamp": header.timestamp, + "cwd": str(header.cwd), + }, + ensure_ascii=False, + ) + + "\n", + encoding="utf-8", + ) + return cls( + header=header, + session_file=session_file, + entries={}, + ordered_ids=[], + leaf_id=None, + ) + + @property + def cwd(self) -> Path: + return self._cwd + + @property + def session_file(self) -> Path: + return self._session_file + + @property + def session_id(self) -> str: + return self._header.session_id + + def get_leaf_id(self) -> str | None: + return self._leaf_id + + def get_entry(self, entry_id: str) -> dict[str, object] | None: + return self._entries.get(entry_id) + + def get_entries(self) -> list[dict[str, object]]: + return [self._entries[entry_id] for entry_id in self._ordered_ids] + + def get_branch(self, from_id: str | None = None) -> list[dict[str, object]]: + """返回 root 到目标 leaf 的路径。""" + + target_id = from_id or self._leaf_id + if target_id is None: + return [] + + branch: list[dict[str, object]] = [] + current_id: str | None = target_id + while current_id is not None: + entry = self._entries.get(current_id) + if entry is None: + raise ValueError(f"missing session entry: {current_id}") + branch.append(entry) + parent_id = entry.get("parentId") + current_id = str(parent_id) if isinstance(parent_id, str) else None + + branch.reverse() + return branch + + def branch(self, entry_id: str) -> None: + """将当前 leaf 切换到既有节点。""" + + if entry_id not in self._entries: + raise ValueError(f"unknown session entry: {entry_id}") + self._leaf_id = entry_id + + def append_message(self, message: AnyMessage) -> str: + """追加普通消息。""" + + return self._append_entry( + { + "type": "message", + "message": _serialize_message(message), + } + ) + + def append_compaction( + self, + *, + summary: str, + first_kept_entry_id: str, + tokens_before: int, + details: dict[str, JsonValue] | None = None, + from_hook: bool | None = None, + ) -> str: + """追加 compaction 记录。""" + + entry: dict[str, object] = { + "type": "compaction", + "summary": summary, + "firstKeptEntryId": first_kept_entry_id, + "tokensBefore": tokens_before, + } + if details is not None: + entry["details"] = details + if from_hook is not None: + entry["fromHook"] = from_hook + return self._append_entry(entry) + + def append_custom_entry( + self, + *, + custom_type: str, + data: dict[str, JsonValue] | list[JsonValue] | JsonValue = None, + ) -> str: + """追加仅持久化、不参与上下文的自定义状态。""" + + return self._append_entry( + { + "type": "custom", + "customType": custom_type, + "data": data, + } + ) + + def append_custom_message_entry( + self, + *, + custom_type: str, + content: JsonValue, + display: bool, + role: Literal["system", "user", "assistant"] = "system", + details: dict[str, JsonValue] | None = None, + ) -> str: + """追加参与上下文的自定义消息。""" + + entry: dict[str, object] = { + "type": "custom_message", + "customType": custom_type, + "content": content, + "display": display, + "role": role, + } + if details is not None: + entry["details"] = details + return self._append_entry(entry) + + def branch_with_summary( + self, + *, + entry_id: str, + summary: str, + details: dict[str, JsonValue] | None = None, + from_hook: bool | None = None, + ) -> str: + """切换到旧节点,并用 branch_summary 保存离开分支的上下文。""" + + previous_leaf = self._leaf_id + self.branch(entry_id) + entry: dict[str, object] = { + "type": "branch_summary", + "fromId": previous_leaf or entry_id, + "summary": summary, + } + if details is not None: + entry["details"] = details + if from_hook is not None: + entry["fromHook"] = from_hook + return self._append_entry(entry) + + def build_session_context(self) -> list[AnyMessage]: + """从当前 leaf 回溯并重建 LLM 上下文。""" + + branch = self.get_branch() + compaction = _latest_compaction_entry(branch) + + context: list[AnyMessage] = [] + start_index = 0 + if compaction is not None: + context.extend(_leading_system_messages(branch)) + summary = str(compaction["summary"]) + context.append(SystemMessage(content=f"Compaction summary:\n{summary}")) + first_kept_entry_id = str(compaction["firstKeptEntryId"]) + start_index = _find_entry_index(branch, first_kept_entry_id) + + for index, entry in enumerate(branch): + if compaction is not None and index < start_index: + continue + if compaction is not None and entry["id"] == compaction["id"]: + continue + + entry_type = str(entry["type"]) + if entry_type == "message": + context.append(_deserialize_message(cast(dict[str, object], entry["message"]))) + continue + if entry_type == "branch_summary": + context.append(SystemMessage(content=f"Branch summary:\n{entry['summary']}")) + continue + if entry_type == "custom_message": + role = cast(Literal["system", "user", "assistant"], entry.get("role", "system")) + context.append(_custom_message_to_langchain(role=role, content=entry["content"])) + + return context + + def _append_entry(self, payload: dict[str, object]) -> str: + entry_id = _entry_id() + entry = { + **payload, + "id": entry_id, + "parentId": self._leaf_id, + "timestamp": _now_iso(), + } + with self._session_file.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry, ensure_ascii=False) + "\n") + self._entries[entry_id] = entry + self._ordered_ids.append(entry_id) + self._leaf_id = entry_id + return entry_id + + +def _resolve_session_root(storage_root: Path | None) -> Path: + if storage_root is not None: + return storage_root.expanduser().resolve() + + env_root = os.getenv(SESSION_ROOT_ENV) + if env_root: + return Path(env_root).expanduser().resolve() + return _DEFAULT_SESSION_ROOT + + +def _cwd_bucket_name(cwd: Path) -> str: + return f"--{str(cwd).replace('/', '-')}--" + + +def _find_session_file(session_dir: Path, session_id: str) -> Path | None: + for path in sorted(session_dir.glob("*.jsonl")): + try: + first_line = path.read_text(encoding="utf-8").splitlines()[0] + except IndexError: + continue + header = cast(dict[str, object], json.loads(first_line)) + if str(header.get("id")) == session_id: + return path + return None + + +def _serialize_message(message: AnyMessage) -> dict[str, JsonValue]: + if isinstance(message, SystemMessage): + return {"role": "system", "content": _to_json_value(message.content)} + if isinstance(message, HumanMessage): + return {"role": "user", "content": _to_json_value(message.content)} + if isinstance(message, ToolMessage): + payload: dict[str, JsonValue] = { + "role": "tool", + "content": _to_json_value(message.content), + "tool_call_id": message.tool_call_id, + } + if message.name is not None: + payload["name"] = message.name + return payload + if isinstance(message, AIMessage): + payload = { + "role": "assistant", + "content": _to_json_value(message.content), + "tool_calls": _to_json_value(message.tool_calls), + "additional_kwargs": _to_json_value(message.additional_kwargs), + "response_metadata": _to_json_value(message.response_metadata), + } + if message.id is not None: + payload["message_id"] = message.id + if message.name is not None: + payload["name"] = message.name + return payload + raise TypeError(f"unsupported message type: {type(message)!r}") + + +def _deserialize_message(payload: dict[str, object]) -> AnyMessage: + role = str(payload["role"]) + content = payload.get("content", "") + if role == "system": + return SystemMessage(content=content) + if role == "user": + return HumanMessage(content=content) + if role == "tool": + return ToolMessage( + content=content, + tool_call_id=str(payload.get("tool_call_id", "")), + name=_optional_str(payload.get("name")), + ) + if role == "assistant": + tool_calls = payload.get("tool_calls", []) + additional_kwargs = payload.get("additional_kwargs", {}) + response_metadata = payload.get("response_metadata", {}) + return AIMessage( + content=content, + tool_calls=cast(list[dict[str, object]], tool_calls if isinstance(tool_calls, list) else []), + additional_kwargs=cast(dict[str, object], additional_kwargs if isinstance(additional_kwargs, dict) else {}), + response_metadata=cast(dict[str, object], response_metadata if isinstance(response_metadata, dict) else {}), + id=_optional_str(payload.get("message_id")), + name=_optional_str(payload.get("name")), + ) + raise ValueError(f"unsupported message role: {role}") + + +def _custom_message_to_langchain(*, role: Literal["system", "user", "assistant"], content: object) -> AnyMessage: + if role == "user": + return HumanMessage(content=content) + if role == "assistant": + return AIMessage(content=content) + return SystemMessage(content=content) + + +def _latest_compaction_entry(branch: list[dict[str, object]]) -> dict[str, object] | None: + for entry in reversed(branch): + if entry["type"] == "compaction": + return entry + return None + + +def _find_entry_index(branch: list[dict[str, object]], entry_id: str) -> int: + for index, entry in enumerate(branch): + if entry["id"] == entry_id: + return index + raise ValueError(f"unknown entry on branch: {entry_id}") + + +def _leading_system_messages(branch: list[dict[str, object]]) -> list[AnyMessage]: + messages: list[AnyMessage] = [] + for entry in branch: + if entry["type"] != "message": + break + payload = cast(dict[str, object], entry["message"]) + if str(payload.get("role")) != "system": + break + messages.append(_deserialize_message(payload)) + return messages + + +def _to_json_value(value: object) -> JsonValue: + if value is None or isinstance(value, bool | int | float | str): + return cast(JsonValue, value) + if isinstance(value, list): + return [_to_json_value(item) for item in value] + if isinstance(value, dict): + return {str(key): _to_json_value(item) for key, item in value.items()} + return str(value) + + +def _optional_str(value: object) -> str | None: + if isinstance(value, str): + return value + return None + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _timestamp_slug(timestamp: str) -> str: + return timestamp.replace(":", "").replace(".", "").replace("+00:00", "Z") + + +def _entry_id() -> str: + return uuid.uuid4().hex[:8] diff --git a/app/agentland-agent/app/services/skills_config.py b/app/agentland-agent/app/services/skills_config.py new file mode 100644 index 0000000..0bba0e0 --- /dev/null +++ b/app/agentland-agent/app/services/skills_config.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +"""deepagents skills 路径配置。""" + +import json +import os +from pathlib import Path + +SKILL_SOURCES_ENV = "AGENTLAND_AGENT_SKILL_SOURCES" + + +def resolve_skill_sources(workspace_path: Path) -> list[str]: + """返回当前工作区可用的 deepagents skill 源目录。""" + + sources: list[Path] = [ + Path.home() / ".deepagents" / "agent" / "skills", + workspace_path / ".deepagents" / "skills", + ] + + raw = os.getenv(SKILL_SOURCES_ENV) + if raw: + for item in _parse_env_sources(raw, workspace_path): + sources.append(item) + + out: list[str] = [] + seen: set[str] = set() + for path in sources: + resolved = path.expanduser().resolve() + value = resolved.as_posix() + if value in seen: + continue + seen.add(value) + out.append(value) + return out + + +def _parse_env_sources(raw: str, workspace_path: Path) -> list[Path]: + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + parsed = [item for item in raw.split(os.pathsep) if item.strip()] + + if not isinstance(parsed, list): + raise ValueError(f"{SKILL_SOURCES_ENV} must be a JSON array or path-separated string") + + sources: list[Path] = [] + for item in parsed: + if not isinstance(item, str) or not item.strip(): + continue + expanded = item.replace("{workspace}", workspace_path.as_posix()) + sources.append(Path(expanded)) + return sources diff --git a/app/agentland-agent/app/services/skills_service.py b/app/agentland-agent/app/services/skills_service.py new file mode 100644 index 0000000..d21aa33 --- /dev/null +++ b/app/agentland-agent/app/services/skills_service.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +"""deepagents skills 支持。""" + +from collections import OrderedDict +from pathlib import Path, PurePosixPath +from typing import cast + +from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage + +from app.services.skills_config import resolve_skill_sources + +try: + from deepagents.backends.filesystem import FilesystemBackend + from deepagents.middleware.skills import SKILLS_SYSTEM_PROMPT, SkillMetadata, _list_skills +except ImportError: # pragma: no cover - exercised only when dependency is absent + FilesystemBackend = None + SkillMetadata = dict[str, object] # type: ignore[misc,assignment] + SKILLS_SYSTEM_PROMPT = "" + _list_skills = None + + +def build_skills_prompt(workspace_path: Path) -> str: + """构造 deepagents 风格的 skills 系统提示词。""" + + if FilesystemBackend is None or _list_skills is None: + return "" + + sources = resolve_skill_sources(workspace_path) + if not sources: + return "" + + backend = FilesystemBackend(root_dir="/", virtual_mode=False) + all_skills: OrderedDict[str, SkillMetadata] = OrderedDict() + + for source in sources: + for skill in _list_skills(backend, source): + skill_name = str(skill["name"]) + if skill_name in all_skills: + del all_skills[skill_name] + all_skills[skill_name] = skill + + if not all_skills: + return "" + + return SKILLS_SYSTEM_PROMPT.format( + skills_locations=_format_skills_locations(sources), + skills_list=_format_skills_list(list(all_skills.values())), + ).strip() + + +def inject_skills_into_messages(messages: list[AnyMessage], workspace_path: Path) -> list[AnyMessage]: + """将 skills 说明注入到消息列表的 system prompt。""" + + skills_prompt = build_skills_prompt(workspace_path) + if not skills_prompt: + return list(messages) + + out = list(messages) + if out and isinstance(out[0], SystemMessage): + out[0] = SystemMessage(content=_append_text_block(out[0].content, skills_prompt)) + return out + + return [SystemMessage(content=skills_prompt), *out] + + +def build_skill_aware_system_prompt(system_prompt: str, workspace_path: Path) -> str: + """将 skills 说明附加到已有 system prompt 字符串。""" + + skills_prompt = build_skills_prompt(workspace_path) + if not skills_prompt: + return system_prompt + return f"{system_prompt}\n\n{skills_prompt}" + + +def _format_skills_locations(sources: list[str]) -> str: + lines: list[str] = [] + for index, source in enumerate(sources): + if not Path(source).exists(): + continue + name = PurePosixPath(source.rstrip("/")).name.capitalize() + suffix = " (higher priority)" if index == len(sources) - 1 else "" + lines.append(f"**{name} Skills**: `{source}`{suffix}") + return "\n".join(lines) + + +def _format_skills_list(skills: list[SkillMetadata]) -> str: + if not skills: + return "(No skills available)" + + lines: list[str] = [] + for raw_skill in skills: + skill = cast(dict[str, object], raw_skill) + lines.append(f"- **{skill['name']}**: {skill['description']}") + allowed_tools = skill.get("allowed_tools") + if isinstance(allowed_tools, list) and allowed_tools: + lines.append(f" -> Allowed tools: {', '.join(str(item) for item in allowed_tools)}") + lines.append(f" -> Read `{skill['path']}` for full instructions") + return "\n".join(lines) + + +def _append_text_block(content: object, extra: str) -> object: + if isinstance(content, str): + return f"{content}\n\n{extra}" + if isinstance(content, list): + out = list(content) + out.append({"type": "text", "text": extra}) + return out + return f"{content}\n\n{extra}" diff --git a/app/agentland-agent/app/services/tools.py b/app/agentland-agent/app/services/tools.py new file mode 100644 index 0000000..94bf279 --- /dev/null +++ b/app/agentland-agent/app/services/tools.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +"""coding agent 使用的工具实现。""" + +import asyncio +import json +import os +import subprocess +import threading +from pathlib import Path + +from langchain_core.tools import tool + +from app.services.mcp_config import load_mcp_server_configs + +try: + from langchain_mcp_adapters.client import MultiServerMCPClient +except ImportError: # pragma: no cover - exercised only when dependency is absent + MultiServerMCPClient = None + +_tool_cache_lock = threading.Lock() +_tool_cache: dict[str, list[object]] = {} + + +@tool +def bash(command: str, cwd: str = "", timeout_ms: int = 0) -> str: + """Run a shell command and return stdout/stderr/exit_code as JSON.""" + if not command: + raise ValueError("missing command") + + timeout = None + if timeout_ms > 0: + timeout = timeout_ms / 1000.0 + + try: + completed = subprocess.run( + ["bash", "-lc", command], + cwd=cwd or None, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + exit_code = completed.returncode + stdout = completed.stdout + stderr = completed.stderr + except subprocess.TimeoutExpired as exc: + # 与常见 shell 约定保持一致:超时退出码使用 124。 + exit_code = 124 + stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "") + stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "") + + return json.dumps({"stdout": stdout, "stderr": stderr, "exit_code": exit_code}, ensure_ascii=False) + + +@tool +def read(path: str, offset: int = 0, max_bytes: int = 65536) -> str: + """Read a file. Returns JSON with content and truncation metadata.""" + if not path: + raise ValueError("missing path") + if max_bytes <= 0: + max_bytes = 65536 + if offset < 0: + raise ValueError("offset must be >= 0") + + file_path = Path(path) + with file_path.open("rb") as handle: + if offset > 0: + handle.seek(offset) + content = handle.read(max_bytes) + # 额外读取 1 字节用于标记是否截断,避免一次性读完整文件。 + truncated = len(handle.read(1)) > 0 + + return json.dumps( + { + "path": path, + "offset": offset, + "bytes_read": len(content), + "truncated": truncated, + "content": content.decode("utf-8", errors="replace"), + }, + ensure_ascii=False, + ) + + +@tool +def write(path: str, content: str, append: bool = False, mkdir: bool = True) -> str: + """Write a file. Returns JSON with bytes_written.""" + if not path: + raise ValueError("missing path") + + file_path = Path(path) + if mkdir: + os.makedirs(file_path.parent, exist_ok=True) + + mode = "a" if append else "w" + with file_path.open(mode, encoding="utf-8") as handle: + written = handle.write(content) + + return json.dumps({"path": path, "bytes_written": written, "appended": append}, ensure_ascii=False) + + +def default_tools() -> list[object]: + """按稳定顺序返回工具列表,便于模型绑定。""" + + return [bash, read, write] + + +def load_tools() -> list[object]: + """返回基础工具加上可选 MCP 工具。""" + + base_tools = default_tools() + mcp_servers = load_mcp_server_configs() + if not mcp_servers: + return base_tools + + cache_key = json.dumps(mcp_servers, sort_keys=True, ensure_ascii=False) + with _tool_cache_lock: + cached = _tool_cache.get(cache_key) + if cached is not None: + return cached + + mcp_tools = _load_mcp_tools_sync(mcp_servers) + merged_tools = [*base_tools, *mcp_tools] + with _tool_cache_lock: + _tool_cache[cache_key] = merged_tools + return merged_tools + + +def tool_signature(tools: list[object]) -> tuple[str, ...]: + """为工具集生成稳定签名,用于缓存绑定模型。""" + + return tuple(_tool_name(tool) for tool in tools) + + +def clear_tool_cache() -> None: + """测试辅助:清空 MCP tool 缓存。""" + + with _tool_cache_lock: + _tool_cache.clear() + + +def _load_mcp_tools_sync(mcp_servers: dict[str, object]) -> list[object]: + if MultiServerMCPClient is None: + raise RuntimeError( + "MCP tools are configured, but langchain-mcp-adapters is not installed. " + "Add it to the environment before starting the service." + ) + return asyncio.run(_load_mcp_tools_async(mcp_servers)) + + +async def _load_mcp_tools_async(mcp_servers: dict[str, object]) -> list[object]: + client = MultiServerMCPClient(mcp_servers) + tools = await client.get_tools() + return list(tools) + + +def _tool_name(tool: object) -> str: + name = getattr(tool, "name", None) + if isinstance(name, str) and name: + return name + function_name = getattr(tool, "__name__", None) + if isinstance(function_name, str) and function_name: + return function_name + return repr(tool) diff --git a/app/agentland-agent/deploy/agentruntime.yaml b/app/agentland-agent/deploy/agentruntime.yaml new file mode 100644 index 0000000..bc29f56 --- /dev/null +++ b/app/agentland-agent/deploy/agentruntime.yaml @@ -0,0 +1,49 @@ +apiVersion: agentland.fl0rencess720.app/v1alpha1 +kind: AgentRuntime +metadata: + name: agentland-agent + namespace: agentland-sandboxes +spec: + sandboxTemplate: + podSpec: + restartPolicy: Never + containers: + - name: agent + image: fl0rences720/agentland-agent:latest + imagePullPolicy: IfNotPresent + env: + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: agentland-agent-secrets + key: OPENAI_API_KEY + - name: OPENAI_BASE_URL + valueFrom: + secretKeyRef: + name: agentland-agent-secrets + key: OPENAI_BASE_URL + - name: AGENTLAND_AGENT_MCP_CONFIG + valueFrom: + secretKeyRef: + name: agentland-agent-secrets + key: AGENTLAND_AGENT_MCP_CONFIG + - name: AGENTLAND_AGENT_SKILL_SOURCES + valueFrom: + secretKeyRef: + name: agentland-agent-secrets + key: AGENTLAND_AGENT_SKILL_SOURCES + - name: PI_SESSION_ROOT + value: /workspace/.pi/sessions + ports: + - containerPort: 8000 + readinessProbe: + httpGet: + path: /health + port: 8000 + - name: korokd + image: fl0rences720/agentland-korokd:latest + imagePullPolicy: IfNotPresent + args: + - --port=1883 + ports: + - containerPort: 1883 diff --git a/app/agentland-agent/deploy/agentsession.yaml b/app/agentland-agent/deploy/agentsession.yaml new file mode 100644 index 0000000..4793244 --- /dev/null +++ b/app/agentland-agent/deploy/agentsession.yaml @@ -0,0 +1,9 @@ +apiVersion: agentland.fl0rencess720.app/v1alpha1 +kind: AgentSession +metadata: + name: agentland-agent-session + namespace: agentland-sandboxes +spec: + runtimeRef: + name: agentland-agent + namespace: agentland-sandboxes diff --git a/app/agentland-agent/deploy/kustomization.yaml b/app/agentland-agent/deploy/kustomization.yaml new file mode 100644 index 0000000..3d0f36a --- /dev/null +++ b/app/agentland-agent/deploy/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: agentland-sandboxes +resources: + - secret.example.yaml + - agentruntime.yaml + - agentsession.yaml diff --git a/app/agentland-agent/deploy/secret.example.yaml b/app/agentland-agent/deploy/secret.example.yaml new file mode 100644 index 0000000..63b6524 --- /dev/null +++ b/app/agentland-agent/deploy/secret.example.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Secret +metadata: + name: agentland-agent-secrets + namespace: agentland-sandboxes +type: Opaque +stringData: + OPENAI_API_KEY: "clp_0fafda88e152432e9afa4e57a86ea4b23c92a9b8f7d789036b79c91f299474cc" + OPENAI_BASE_URL: "https://api-vip.codex-for.me/v1" + AGENTLAND_AGENT_MCP_CONFIG: | + {} + AGENTLAND_AGENT_SKILL_SOURCES: | + [] \ No newline at end of file diff --git a/app/agentland-agent/requirements.txt b/app/agentland-agent/requirements.txt new file mode 100644 index 0000000..8bda52a --- /dev/null +++ b/app/agentland-agent/requirements.txt @@ -0,0 +1,8 @@ +langgraph>=1.0.0 +deepagents>=0.4.10 +langchain-openai>=0.2.0 +langchain-core>=0.3.0 +langchain-mcp-adapters>=0.1.0 +fastapi>=0.115.0 +uvicorn>=0.30.0 +pytest>=8.0.0 diff --git a/app/agentland-agent/tests/__init__.py b/app/agentland-agent/tests/__init__.py new file mode 100644 index 0000000..ee68d2f --- /dev/null +++ b/app/agentland-agent/tests/__init__.py @@ -0,0 +1,2 @@ +"""测试包。""" + diff --git a/app/agentland-agent/tests/test_chat_graph.py b/app/agentland-agent/tests/test_chat_graph.py new file mode 100644 index 0000000..2f232e5 --- /dev/null +++ b/app/agentland-agent/tests/test_chat_graph.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +"""Unified chat graph and persistent memory tests.""" + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + +pytest.importorskip("langgraph") + +from app.main import app +from app.models.ralph import RalphPrd +from app.services.chat_router import _invoke_router_model +from app.services.session_memory import SessionManager + + +def _run_dir(workspace: Path, session_id: str, run_id: str = "run-0001") -> Path: + return workspace / ".ralph" / session_id / run_id + + +def test_session_manager_persists_and_rebuilds_context(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """SessionManager should rebuild persisted message context from JSONL.""" + + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + workspace = tmp_path / "workspace" + workspace.mkdir() + + manager = SessionManager.open_or_create( + cwd=workspace, + session_id="session-memory", + system_prompt="You are persistent.", + ) + manager.append_message(HumanMessage(content="hello")) + manager.append_message(AIMessage(content="world")) + + reopened = SessionManager.open(manager.session_file) + context = reopened.build_session_context() + + assert [type(message) for message in context] == [SystemMessage, HumanMessage, AIMessage] + assert str(context[0].content) == "You are persistent." + assert str(context[1].content) == "hello" + assert str(context[2].content) == "world" + + +def test_session_manager_supports_branch_summaries(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Branch summaries should preserve abandoned-branch context on the new leaf.""" + + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + workspace = tmp_path / "workspace" + workspace.mkdir() + + manager = SessionManager.open_or_create( + cwd=workspace, + session_id="session-branch", + system_prompt="sys", + ) + manager.append_message(HumanMessage(content="first")) + first_reply_id = manager.append_message(AIMessage(content="reply-1")) + manager.append_message(HumanMessage(content="second")) + manager.append_message(AIMessage(content="reply-2")) + manager.branch_with_summary(entry_id=first_reply_id, summary="The abandoned branch already handled the second turn.") + manager.append_message(HumanMessage(content="retry")) + + reopened = SessionManager.open(manager.session_file) + context = reopened.build_session_context() + rendered = [str(message.content) for message in context] + + assert rendered == [ + "sys", + "first", + "reply-1", + "Branch summary:\nThe abandoned branch already handled the second turn.", + "retry", + ] + + +def test_chat_stream_routes_to_chat_branch(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Unified chat endpoint should route normal chat prompts to the chat agent.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + + workspace = tmp_path / "workspace" + workspace.mkdir() + client = TestClient(app) + + def fake_get_bound_model(*, api_key: str, model: str, base_url: str | None, timeout: float) -> object: + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + reply = AIMessage(content="你好,我在。") + if cfg.hooks.on_assistant is not None: + cfg.hooks.on_assistant(reply) + return [*messages, reply] + + monkeypatch.setattr( + "app.services.chat_service.route_prompt", + lambda **_: pytest.fail("route_prompt should not be called when deep=false"), + ) + monkeypatch.setattr("app.services.chat_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.chat_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/chat/stream", + json={ + "session_id": "chat-route", + "workspace_path": str(workspace), + "message": "你好", + "deep": False, + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert 'event: route' in body + assert '"intent": "chat"' in body + assert 'event: session' in body + assert 'event: assistant_delta' in body + assert '"mode": "chat"' in body + + session_root = Path(str(tmp_path / "pi-sessions")) + session_files = list(session_root.rglob("*.jsonl")) + assert len(session_files) == 1 + entries = [json.loads(line) for line in session_files[0].read_text(encoding="utf-8").splitlines()] + assert entries[0]["type"] == "session" + assert entries[1]["message"]["role"] == "system" + assert entries[2]["message"]["role"] == "user" + assert entries[2]["message"]["content"] == "你好" + assert entries[3]["message"]["role"] == "assistant" + + +def test_chat_stream_routes_to_task_branch(monkeypatch: pytest.MonkeyPatch) -> None: + """Unified chat endpoint should route task prompts to Ralph.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + client = TestClient(app) + + def fake_route_prompt(*, messages, api_key: str, model: str, base_url: str | None, timeout: float) -> str: # noqa: ANN001 + assert "创建文件" in str(messages[-1].content) + return "task" + + def fake_run_ralph(*, request, emit): # noqa: ANN001 + emit("session", {"session_id": request.session_id, "workspace_path": request.workspace_path}) + emit("done", {"session_id": request.session_id, "status": "complete", "iteration": 1}) + + monkeypatch.setattr("app.services.chat_service.route_prompt", fake_route_prompt) + monkeypatch.setattr("app.services.chat_service.run_ralph", fake_run_ralph) + + with client.stream( + "POST", + "/v1/chat/stream", + json={ + "session_id": "task-route", + "workspace_path": "/tmp/task-route", + "message": "请创建文件并记录进度", + "deep": True, + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert 'event: route' in body + assert '"intent": "task"' in body + assert 'event: session' in body + assert '"status": "complete"' in body + + +def test_router_model_uses_streaming_json(monkeypatch: pytest.MonkeyPatch) -> None: + """Router model path should use stream=true and parse JSON output.""" + + captured: dict[str, object] = {} + + class FakeChunk: + def __init__(self, content: str) -> None: + self.content = content + + def __add__(self, other: "FakeChunk") -> "FakeChunk": + return FakeChunk(self.content + other.content) + + class FakeRouter: + def __init__(self, **kwargs: object) -> None: + captured["kwargs"] = kwargs + + def stream(self, messages: list[object]) -> list[FakeChunk]: + captured["messages"] = messages + return [ + FakeChunk('{"intent":"task",'), + FakeChunk('"reason":"needs workspace changes"}'), + ] + + monkeypatch.setattr("app.services.chat_router.ChatOpenAI", FakeRouter) + decision = _invoke_router_model( + messages=[HumanMessage(content="请创建一个文件")], + api_key="test-key", + model="gpt-5.2-codex", + base_url="https://example.com/v1", + timeout=30.0, + ) + + assert decision == "task" + assert captured["kwargs"]["streaming"] is True + assert captured["kwargs"]["use_responses_api"] is False + + +def test_chat_to_ralph_to_chat_reuses_memory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Prior chat memory should inform Ralph planning, and Ralph results should return to chat memory.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + + workspace = tmp_path / "workspace" + workspace.mkdir() + client = TestClient(app) + session_id = "memory-handoff" + run_dir = _run_dir(workspace, session_id) + chat_turn = {"count": 0} + captured: dict[str, str] = {} + third_chat_context: dict[str, list[str]] = {} + + def fake_route_prompt(*, messages, api_key: str, model: str, base_url: str | None, timeout: float) -> str: # noqa: ANN001 + latest = str(messages[-1].content) + if "创建" in latest or "create" in latest.lower(): + return "task" + return "chat" + + def fake_chat_get_bound_model(*, api_key: str, model: str, base_url: str | None, timeout: float) -> object: + return object() + + def fake_ralph_get_bound_model(*, api_key: str, model: str, base_url: str | None, timeout: float) -> object: + return object() + + def fake_chat_run_agent(messages, cfg): # noqa: ANN001 + chat_turn["count"] += 1 + if chat_turn["count"] == 1: + reply = AIMessage(content="记住:我偏好 TypeScript。") + else: + rendered = [str(message.content) for message in messages if isinstance(message, (SystemMessage, HumanMessage, AIMessage))] + third_chat_context["messages"] = rendered + reply = AIMessage(content="我记得你偏好 TypeScript,而且 Ralph 已完成 hello.txt。") + if cfg.hooks.on_assistant is not None: + cfg.hooks.on_assistant(reply) + return [*messages, reply] + + def fake_plan_prd_from_requirement(*, request, run_state, api_key, planner_memory=""): # noqa: ANN001 + captured["planner_memory"] = planner_memory + return RalphPrd.model_validate( + { + "project": "demo", + "branchName": "ralph/hello-task", + "description": "Create hello.txt", + "userStories": [ + { + "id": "US-001", + "title": "Create hello.txt", + "description": "Create hello.txt in the workspace root", + "acceptanceCriteria": [ + "A file named hello.txt exists at the repository root", + 'The file content equals exactly: "hello ralph"', + ], + "priority": 1, + "passes": False, + "notes": "", + } + ], + } + ) + + def fake_ralph_run_agent(messages, cfg): # noqa: ANN001 + (workspace / "hello.txt").write_text("hello ralph\n", encoding="utf-8") + payload = json.loads((run_dir / "prd.json").read_text(encoding="utf-8")) + payload["userStories"][0]["passes"] = True + (run_dir / "prd.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + (run_dir / "progress.txt").write_text( + "# Ralph Progress Log\n" + "Started: now\n" + "---\n" + "## 2026-03-12T20:00:00 - US-001\n" + "- Created hello.txt with content hello ralph.\n" + "---\n", + encoding="utf-8", + ) + return [*messages, AIMessage(content="COMPLETE")] + + monkeypatch.setattr("app.services.chat_service.route_prompt", fake_route_prompt) + monkeypatch.setattr("app.services.chat_service._get_bound_model", fake_chat_get_bound_model) + monkeypatch.setattr("app.services.chat_service.run_agent", fake_chat_run_agent) + monkeypatch.setattr("app.services.ralph_service._get_bound_model", fake_ralph_get_bound_model) + monkeypatch.setattr("app.services.ralph_service._plan_prd_from_requirement", fake_plan_prd_from_requirement) + monkeypatch.setattr("app.services.ralph_service.run_agent", fake_ralph_run_agent) + + with client.stream( + "POST", + "/v1/chat/stream", + json={ + "session_id": session_id, + "workspace_path": str(workspace), + "message": "记住我的偏好是 TypeScript", + "deep": False, + }, + ) as response: + first_body = "".join(response.iter_text()) + + with client.stream( + "POST", + "/v1/chat/stream", + json={ + "session_id": session_id, + "workspace_path": str(workspace), + "message": "请创建 hello.txt,内容是 hello ralph", + "deep": True, + }, + ) as response: + second_body = "".join(response.iter_text()) + + with client.stream( + "POST", + "/v1/chat/stream", + json={ + "session_id": session_id, + "workspace_path": str(workspace), + "message": "你还记得刚才做了什么吗?", + "deep": False, + }, + ) as response: + third_body = "".join(response.iter_text()) + + assert "记住:我偏好 TypeScript。" in first_body + assert '"intent": "task"' in second_body + assert '"status": "complete"' in second_body + assert "我偏好 TypeScript" in captured["planner_memory"] + assert "messages" in third_chat_context + assert any("TypeScript" in item for item in third_chat_context["messages"]) + assert any("Ralph task result" in item for item in third_chat_context["messages"]) + assert any("hello.txt" in item for item in third_chat_context["messages"]) + assert "我记得你偏好 TypeScript,而且 Ralph 已完成 hello.txt。" in third_body + + session_root = Path(str(tmp_path / "pi-sessions")) + session_files = list(session_root.rglob(f"*{session_id}.jsonl")) + assert len(session_files) == 1 + reopened = SessionManager.open(session_files[0]) + rendered = [str(message.content) for message in reopened.build_session_context()] + assert any("Ralph task result" in item for item in rendered) diff --git a/app/agentland-agent/tests/test_main.py b/app/agentland-agent/tests/test_main.py new file mode 100644 index 0000000..cf6416d --- /dev/null +++ b/app/agentland-agent/tests/test_main.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +"""FastAPI 入口基础测试。""" + +import pytest +from fastapi.testclient import TestClient + +pytest.importorskip("langgraph") + +from app.main import app + + +def test_health() -> None: + """健康检查应返回 200 + ok。""" + + client = TestClient(app) + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/app/agentland-agent/tests/test_mcp_tools.py b/app/agentland-agent/tests/test_mcp_tools.py new file mode 100644 index 0000000..4069585 --- /dev/null +++ b/app/agentland-agent/tests/test_mcp_tools.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +"""MCP tool loading tests.""" + +from pathlib import Path + +from app.services.mcp_config import load_mcp_server_configs +from app.services.tools import clear_tool_cache, load_tools, tool_signature + + +def test_load_mcp_server_configs_from_env_path(monkeypatch, tmp_path: Path) -> None: # noqa: ANN001 + """MCP config should load from JSON file and expand env vars.""" + + config_path = tmp_path / "mcp.json" + monkeypatch.setenv("MCP_TOKEN", "secret-token") + monkeypatch.setenv("AGENTLAND_AGENT_MCP_CONFIG_PATH", str(config_path)) + config_path.write_text( + """ + { + "weather": { + "transport": "streamable_http", + "url": "http://127.0.0.1:9000/mcp", + "headers": { + "Authorization": "Bearer ${MCP_TOKEN}" + } + } + } + """.strip(), + encoding="utf-8", + ) + + configs = load_mcp_server_configs() + assert configs == { + "weather": { + "transport": "streamable_http", + "url": "http://127.0.0.1:9000/mcp", + "headers": {"Authorization": "Bearer secret-token"}, + "args": [], + "env": {}, + "session_kwargs": {}, + } + } + + +def test_load_tools_merges_builtin_and_mcp_tools(monkeypatch) -> None: # noqa: ANN001 + """Configured MCP tools should be merged into the builtin tool list.""" + + captured: dict[str, object] = {} + + class FakeMcpTool: + def __init__(self, name: str) -> None: + self.name = name + + class FakeClient: + def __init__(self, servers: dict[str, object]) -> None: + captured["servers"] = servers + + async def get_tools(self) -> list[object]: + return [FakeMcpTool("weather_lookup"), FakeMcpTool("math_add")] + + monkeypatch.setattr( + "app.services.tools.load_mcp_server_configs", + lambda: { + "weather": { + "transport": "streamable_http", + "url": "http://127.0.0.1:9000/mcp", + } + }, + ) + monkeypatch.setattr("app.services.tools.MultiServerMCPClient", FakeClient) + clear_tool_cache() + + tools = load_tools() + + assert captured["servers"] == { + "weather": { + "transport": "streamable_http", + "url": "http://127.0.0.1:9000/mcp", + } + } + assert tool_signature(tools) == ("bash", "read", "write", "weather_lookup", "math_add") diff --git a/app/agentland-agent/tests/test_memory_compaction.py b/app/agentland-agent/tests/test_memory_compaction.py new file mode 100644 index 0000000..cf906f3 --- /dev/null +++ b/app/agentland-agent/tests/test_memory_compaction.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + +pytest.importorskip("langgraph") + +from app.schemas.chat import ChatStreamRequest +from app.services.memory_compaction import ( + CompactionSettings, + compact_session, + prepare_compaction, + serialize_conversation, +) +from app.services.chat_service import _run_chat_branch +from app.services.session_memory import SessionManager + + +def test_compaction_rebuilds_context_from_summary(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + + workspace = tmp_path / "workspace" + workspace.mkdir() + manager = SessionManager.open_or_create( + cwd=workspace, + session_id="compact-replay", + system_prompt="sys", + ) + manager.append_message(HumanMessage(content="first")) + manager.append_message(AIMessage(content="reply-1")) + manager.append_message(HumanMessage(content="second")) + manager.append_message(AIMessage(content="reply-2")) + + monkeypatch.setattr( + "app.services.memory_compaction._generate_summary_text", + lambda **kwargs: "## Goal\nSummarized old work.", + ) + result = compact_session( + manager=manager, + model="gpt-5.2-codex", + api_key="test-key", + base_url=None, + timeout=30.0, + settings=CompactionSettings(enabled=True, reserve_tokens=16, keep_recent_tokens=3), + ) + + assert result is not None + context = manager.build_session_context() + rendered = [str(message.content) for message in context] + assert rendered == [ + "sys", + "Compaction summary:\n## Goal\nSummarized old work.", + "second", + "reply-2", + ] + + reopened = SessionManager.open(manager.session_file) + entry_types = [str(entry["type"]) for entry in reopened.get_entries()] + assert "compaction" in entry_types + + +def test_prepare_compaction_marks_split_turn(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + + workspace = tmp_path / "workspace" + workspace.mkdir() + manager = SessionManager.open_or_create( + cwd=workspace, + session_id="split-turn", + system_prompt="sys", + ) + manager.append_message(HumanMessage(content="implement feature")) + manager.append_message( + AIMessage( + content="Reading project files.", + tool_calls=[{"id": "call-1", "name": "read", "args": {"path": str(workspace / "README.md")}}], + ) + ) + manager.append_message( + ToolMessage( + content="A" * 64, + tool_call_id="call-1", + name="read", + ) + ) + manager.append_message(AIMessage(content="Applying the change now.")) + + preparation = prepare_compaction( + manager.get_branch(), + CompactionSettings(enabled=True, reserve_tokens=16, keep_recent_tokens=1), + ) + + assert preparation is not None + assert preparation.is_split_turn is True + assert [type(message) for message in preparation.turn_prefix_messages] == [ + HumanMessage, + AIMessage, + ToolMessage, + ] + assert preparation.messages_to_summarize == [] + + +def test_compaction_tracks_files_and_truncates_tool_results( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + + workspace = tmp_path / "workspace" + workspace.mkdir() + target = workspace / "hello.txt" + manager = SessionManager.open_or_create( + cwd=workspace, + session_id="file-tracking", + system_prompt="sys", + ) + manager.append_message(HumanMessage(content="read the file")) + manager.append_message( + AIMessage( + content="I will inspect it.", + tool_calls=[{"id": "call-1", "name": "read", "args": {"path": str(target)}}], + ) + ) + manager.append_message( + ToolMessage( + content="B" * 2505, + tool_call_id="call-1", + name="read", + ) + ) + manager.append_message(HumanMessage(content="done")) + manager.append_message(AIMessage(content="done")) + + serialization = serialize_conversation( + [ + AIMessage( + content="I will inspect it.", + tool_calls=[{"id": "call-1", "name": "read", "args": {"path": str(target)}}], + ), + ToolMessage(content="B" * 2505, tool_call_id="call-1", name="read"), + ] + ) + assert "[Assistant tool calls]: read(" in serialization + assert "more characters truncated" in serialization + + monkeypatch.setattr( + "app.services.memory_compaction._generate_summary_text", + lambda **kwargs: "## Goal\nTrack read files.", + ) + result = compact_session( + manager=manager, + model="gpt-5.2-codex", + api_key="test-key", + base_url=None, + timeout=30.0, + settings=CompactionSettings(enabled=True, reserve_tokens=16, keep_recent_tokens=3), + ) + + assert result is not None + assert result.details["readFiles"] == [str(target)] + assert result.details["modifiedFiles"] == [] + + +def test_chat_branch_emits_auto_compaction_events(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + monkeypatch.setenv("AGENTLAND_CONTEXT_WINDOW", "12") + monkeypatch.setenv("AGENTLAND_COMPACTION_RESERVE_TOKENS", "2") + monkeypatch.setenv("AGENTLAND_COMPACTION_KEEP_RECENT_TOKENS", "1") + + workspace = tmp_path / "workspace" + workspace.mkdir() + + def fake_get_bound_model(*, api_key: str, model: str, base_url: str | None, timeout: float) -> object: + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + reply = AIMessage( + content="This answer is long enough to trigger compaction.", + usage_metadata={"input_tokens": 8, "output_tokens": 8, "total_tokens": 16}, + ) + if cfg.hooks.on_assistant is not None: + cfg.hooks.on_assistant(reply) + return [*messages, reply] + + monkeypatch.setattr("app.services.chat_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.chat_service.run_agent", fake_run_agent) + monkeypatch.setattr( + "app.services.memory_compaction._generate_summary_text", + lambda **kwargs: "## Goal\nAuto compact chat memory.", + ) + + events: list[tuple[str, dict[str, object]]] = [] + _run_chat_branch( + request=ChatStreamRequest( + session_id="chat-auto-compact", + workspace_path=str(workspace), + message="hello", + ), + session_id="chat-auto-compact", + api_key="test-key", + emit=lambda event, data: events.append((event, data)), + ) + + assert any(event == "auto_compaction_start" for event, _ in events) + assert any(event == "auto_compaction_end" for event, _ in events) + + session_root = tmp_path / "pi-sessions" + session_files = list(session_root.rglob("*chat-auto-compact.jsonl")) + assert len(session_files) == 1 + entries = [json.loads(line) for line in session_files[0].read_text(encoding="utf-8").splitlines()] + assert any(entry.get("type") == "compaction" for entry in entries[1:]) + + reopened = SessionManager.open(session_files[0]) + rendered = [str(message.content) for message in reopened.build_session_context()] + assert any("Compaction summary:" in item and "Auto compact chat memory." in item for item in rendered) diff --git a/app/agentland-agent/tests/test_ralph.py b/app/agentland-agent/tests/test_ralph.py new file mode 100644 index 0000000..b799949 --- /dev/null +++ b/app/agentland-agent/tests/test_ralph.py @@ -0,0 +1,635 @@ +from __future__ import annotations + +"""Ralph orchestration tests.""" + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + +pytest.importorskip("langgraph") + +from app.main import app +from app.models.ralph import RalphPrd +from app.schemas.ralph import RalphStreamRequest +from app.services.session_memory import SessionManager + + +def _run_dir(workspace: Path, session_id: str, run_id: str = "run-0001") -> Path: + return workspace / ".ralph" / session_id / run_id + + +def test_ralph_stream_runs_fresh_iterations(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Each Ralph iteration should start the agent with fresh chat context.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + + client = TestClient(app) + session_id = "ralph-test" + run_dir = _run_dir(tmp_path, session_id) + prd_path = run_dir / "prd.json" + calls: list[list[object]] = [] + + def fake_plan_prd_from_requirement(*, request, run_state, api_key): # noqa: ANN001 + return RalphPrd.model_validate( + { + "project": "demo", + "branchName": "ralph/test-feature", + "description": "Test feature", + "userStories": [ + { + "id": "US-001", + "title": "Implement test feature", + "description": "Make the test pass", + "acceptanceCriteria": ["Write code", "Run checks"], + "priority": 1, + "passes": False, + "notes": "", + } + ], + } + ) + + def fake_get_bound_model(*, api_key, model, base_url, timeout): # noqa: ANN001 + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + calls.append(messages) + assert len(messages) == 2 + assert isinstance(messages[0], SystemMessage) + assert isinstance(messages[1], HumanMessage) + assert "Ralph iteration" in str(messages[1].content) + + if len(calls) < 3: + return [*messages, AIMessage(content=f"iteration {len(calls)} incomplete")] + + payload = json.loads(prd_path.read_text(encoding="utf-8")) + payload["userStories"][0]["passes"] = True + prd_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return [*messages, AIMessage(content="COMPLETE")] + + monkeypatch.setattr("app.services.ralph_service._plan_prd_from_requirement", fake_plan_prd_from_requirement) + monkeypatch.setattr("app.services.ralph_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.ralph_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/ralph/stream", + json={ + "session_id": session_id, + "workspace_path": str(tmp_path), + "requirement": "Build a test feature.", + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert "event: session" in body + assert "event: plan_ready" in body + assert "event: iteration_start" in body + assert '"status": "complete"' in body + assert len(calls) == 3 + assert all(len(call) == 2 for call in calls) + assert "Ralph iteration 1 of 10." in str(calls[0][1].content) + assert "Ralph iteration 2 of 10." in str(calls[1][1].content) + assert "Ralph iteration 3 of 10." in str(calls[2][1].content) + assert prd_path.exists() + assert (run_dir / "progress.txt").exists() + + +def test_ralph_stream_resumes_existing_run(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Existing Ralph file state should be reused without replanning.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + + client = TestClient(app) + session_id = "ralph-resume" + run_dir = _run_dir(tmp_path, session_id) + run_dir.mkdir(parents=True) + (run_dir / "prd.json").write_text( + json.dumps( + { + "project": "demo", + "branchName": "ralph/existing-run", + "description": "Existing run", + "userStories": [ + { + "id": "US-001", + "title": "Continue work", + "description": "Resume existing plan", + "acceptanceCriteria": ["Finish work"], + "priority": 1, + "passes": False, + "notes": "", + } + ], + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + (run_dir / "progress.txt").write_text("# Ralph Progress Log\nStarted: now\n---\n", encoding="utf-8") + + def fail_if_called(*args, **kwargs): # noqa: ANN002,ANN003 + raise AssertionError("planner should not be called when prd.json already exists") + + def fake_get_bound_model(*, api_key, model, base_url, timeout): # noqa: ANN001 + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + payload = json.loads((run_dir / "prd.json").read_text(encoding="utf-8")) + payload["userStories"][0]["passes"] = True + (tmp_path / "done.txt").write_text("done\n", encoding="utf-8") + (run_dir / "prd.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return [*messages, AIMessage(content="COMPLETE")] + + monkeypatch.setattr("app.services.ralph_service._plan_prd_from_requirement", fail_if_called) + monkeypatch.setattr("app.services.ralph_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.ralph_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/ralph/stream", + json={ + "session_id": session_id, + "workspace_path": str(tmp_path), + "requirement": "Resume the existing run.", + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert '"status": "complete"' in body + + +def test_ralph_stream_starts_new_run_after_previous_run_completed( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """A completed run should not block a new requirement in the same chat session.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + + client = TestClient(app) + session_id = "ralph-multi-run" + first_run_dir = _run_dir(tmp_path, session_id, "run-0001") + second_run_dir = _run_dir(tmp_path, session_id, "run-0002") + first_run_dir.mkdir(parents=True) + (first_run_dir / "prd.json").write_text( + json.dumps( + { + "project": "demo", + "branchName": "ralph/first-run", + "description": "Completed first run", + "userStories": [ + { + "id": "US-001", + "title": "Finish first run", + "description": "Already complete", + "acceptanceCriteria": ["Work is done"], + "priority": 1, + "passes": True, + "notes": "", + } + ], + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + (first_run_dir / "progress.txt").write_text( + "# Ralph Progress Log\nStarted: now\n---\n## 2026-03-15T00:00:00 - US-001\n- Completed first run.\n---\n", + encoding="utf-8", + ) + planner_calls: list[str] = [] + + def fake_plan_prd_from_requirement(*, request, run_state, api_key): # noqa: ANN001 + planner_calls.append(run_state.run_id) + assert run_state.run_id == "run-0002" + return RalphPrd.model_validate( + { + "project": "demo", + "branchName": "ralph/second-run", + "description": "Plan a fresh second run", + "userStories": [ + { + "id": "US-002", + "title": "Handle new requirement", + "description": "Run a new task instead of reusing the old PRD", + "acceptanceCriteria": ["New run finishes successfully"], + "priority": 1, + "passes": False, + "notes": "", + } + ], + } + ) + + def fake_get_bound_model(*, api_key, model, base_url, timeout): # noqa: ANN001 + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + payload = json.loads((second_run_dir / "prd.json").read_text(encoding="utf-8")) + payload["userStories"][0]["passes"] = True + (second_run_dir / "prd.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return [*messages, AIMessage(content="COMPLETE")] + + monkeypatch.setattr("app.services.ralph_service._plan_prd_from_requirement", fake_plan_prd_from_requirement) + monkeypatch.setattr("app.services.ralph_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.ralph_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/ralph/stream", + json={ + "session_id": session_id, + "workspace_path": str(tmp_path), + "requirement": "Start a different task after the first run is done.", + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert '"status": "complete"' in body + assert planner_calls == ["run-0002"] + assert str(second_run_dir) in body + assert (second_run_dir / "prd.json").exists() + + +def test_ralph_stream_falls_back_when_planner_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Planner failures should not prevent the Ralph loop from starting.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + + client = TestClient(app) + session_id = "ralph-fallback" + run_dir = _run_dir(tmp_path, session_id) + + def fail_planner(*, request, run_state, api_key): # noqa: ANN001 + raise RuntimeError("'str' object has no attribute 'error'") + + def fake_get_bound_model(*, api_key, model, base_url, timeout): # noqa: ANN001 + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + payload = json.loads((run_dir / "prd.json").read_text(encoding="utf-8")) + assert payload["userStories"][0]["notes"] == "Fallback PRD generated because automatic planning failed." + payload["userStories"][0]["passes"] = True + (tmp_path / "hello.txt").write_text("hello\n", encoding="utf-8") + (run_dir / "prd.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return [*messages, AIMessage(content="COMPLETE")] + + monkeypatch.setattr("app.services.ralph_service._plan_prd_with_model", fail_planner) + monkeypatch.setattr("app.services.ralph_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.ralph_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/ralph/stream", + json={ + "session_id": session_id, + "workspace_path": str(tmp_path), + "requirement": "Create hello.txt and log progress.", + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert '"status": "complete"' in body + assert (run_dir / "prd.json").exists() + assert "event: planner_fallback" in body + assert "RuntimeError: 'str' object has no attribute 'error'" in body + + +def test_plan_prd_with_model_uses_streaming(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Planner should use streaming mode for gateways that require stream=true.""" + + captured: dict[str, object] = {} + + class FakeChunk: + def __init__(self, content: str) -> None: + self.content = content + self.tool_calls: list[object] = [] + self.additional_kwargs: dict[str, object] = {} + self.response_metadata: dict[str, object] = {} + self.id = None + + def __add__(self, other: "FakeChunk") -> "FakeChunk": + return FakeChunk(self.content + other.content) + + class FakePlanner: + def __init__(self, **kwargs: object) -> None: + captured["kwargs"] = kwargs + + def stream(self, messages: list[object]) -> list[FakeChunk]: + captured["messages"] = messages + return [ + FakeChunk('{"project":"demo","branchName":"ralph/test","description":"d",'), + FakeChunk('"userStories":[{"id":"US-001","title":"t","description":"d","acceptanceCriteria":["a"],"priority":1,"passes":false,"notes":""}]}'), + ] + + monkeypatch.setattr("app.services.ralph_service.ChatOpenAI", FakePlanner) + + from app.models.ralph import RalphRunState + from app.services.ralph_service import _plan_prd_with_model + + prd = _plan_prd_with_model( + request=RalphStreamRequest(requirement="test requirement"), + run_state=RalphRunState( + session_id="test", + run_id="run-0001", + workspace_path=tmp_path, + session_root=tmp_path / ".ralph" / "test", + run_dir=tmp_path / ".ralph" / "test" / "run-0001", + prd_path=tmp_path / ".ralph" / "test" / "run-0001" / "prd.json", + progress_path=tmp_path / ".ralph" / "test" / "run-0001" / "progress.txt", + ), + api_key="test-key", + project_name="demo", + ) + + assert prd.branchName == "ralph/test" + assert captured["kwargs"]["streaming"] is True + assert captured["kwargs"]["use_responses_api"] is False + + +def test_ralph_stream_reconciles_progress_and_skill_stories( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Workspace artifacts should reconcile bookkeeping stories to passed.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + + client = TestClient(app) + session_id = "ralph-reconcile" + run_dir = _run_dir(tmp_path, session_id) + + def fake_plan_prd_from_requirement(*, request, run_state, api_key): # noqa: ANN001 + return RalphPrd.model_validate( + { + "project": "demo", + "branchName": "ralph/reconcile", + "description": "Create a file and record progress with a skill", + "userStories": [ + { + "id": "US1", + "title": "Create ralph-skill.txt", + "description": "Create a file named ralph-skill.txt with the requested content.", + "acceptanceCriteria": [ + "A file named ralph-skill.txt exists at the repository root", + 'The file content equals exactly: "ralph skill ok"', + ], + "priority": 1, + "passes": False, + "notes": "", + }, + { + "id": "US2", + "title": "Update Ralph progress log", + "description": "Update the Ralph progress log to record the work.", + "acceptanceCriteria": [ + "Ralph progress log contains a new entry mentioning creation of ralph-skill.txt and use of progress-note skill", + "Log entry is appended (not overwriting prior entries)", + ], + "priority": 2, + "passes": False, + "notes": "", + }, + { + "id": "US3", + "title": "Record progress using progress-note skill", + "description": "Use the progress-note skill to add a progress note about the work performed.", + "acceptanceCriteria": [ + "A progress note is created via the progress-note skill describing the created file and log update" + ], + "priority": 3, + "passes": False, + "notes": "", + }, + ], + } + ) + + def fake_get_bound_model(*, api_key, model, base_url, timeout): # noqa: ANN001 + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + target = tmp_path / "ralph-skill.txt" + target.write_text("ralph skill ok\n", encoding="utf-8") + + progress_path = run_dir / "progress.txt" + progress_path.write_text( + "# Ralph Progress Log\n" + "Started: now\n" + "---\n" + "## 2026-03-12T18:39:30 - US1\n" + "- Created ralph-skill.txt with content \"ralph skill ok\".\n" + "- Files changed:\n" + " - ralph-skill.txt\n" + "- Learnings for future iterations:\n" + " - Patterns discovered: learned from progress-note skill\n" + " - Gotchas encountered: None\n" + " - Useful context: Initial story creation.\n" + "---\n", + encoding="utf-8", + ) + + skill_dir = tmp_path / ".deepagents" / "skills" / "progress-note" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: progress-note +description: Use this skill when the user asks you to use the progress-note skill. +--- +""", + encoding="utf-8", + ) + (skill_dir / "notes.log").write_text( + "2026-03-12T18:39:30 - Created ralph-skill.txt and updated progress log.\n", + encoding="utf-8", + ) + return [*messages, AIMessage(content="work completed")] + + monkeypatch.setattr("app.services.ralph_service._plan_prd_from_requirement", fake_plan_prd_from_requirement) + monkeypatch.setattr("app.services.ralph_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.ralph_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/ralph/stream", + json={ + "session_id": session_id, + "workspace_path": str(tmp_path), + "requirement": "Create ralph-skill.txt and log progress with the progress-note skill.", + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert '"status": "complete"' in body + payload = json.loads((run_dir / "prd.json").read_text(encoding="utf-8")) + assert [story["passes"] for story in payload["userStories"]] == [True, True, True] + + +def test_ralph_planner_receives_prior_session_memory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Planner input should include prior persisted chat memory.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + + workspace = tmp_path / "workspace" + workspace.mkdir() + manager = SessionManager.open_or_create( + cwd=workspace, + session_id="ralph-memory", + system_prompt="You are persistent.", + ) + manager.append_message(HumanMessage(content="Earlier chat preference")) + manager.append_message(AIMessage(content="I will remember this context.")) + + client = TestClient(app) + run_dir = _run_dir(workspace, "ralph-memory") + captured: dict[str, str] = {} + + def fake_plan_prd_from_requirement(*, request, run_state, api_key, planner_memory=""): # noqa: ANN001 + captured["planner_memory"] = planner_memory + return RalphPrd.model_validate( + { + "project": "demo", + "branchName": "ralph/memory-aware", + "description": "Memory aware task", + "userStories": [ + { + "id": "US-001", + "title": "Finish the task", + "description": "Use prior memory during planning", + "acceptanceCriteria": ["Task is completed"], + "priority": 1, + "passes": False, + "notes": "", + } + ], + } + ) + + def fake_get_bound_model(*, api_key, model, base_url, timeout): # noqa: ANN001 + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + payload = json.loads((run_dir / "prd.json").read_text(encoding="utf-8")) + payload["userStories"][0]["passes"] = True + (run_dir / "prd.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return [*messages, AIMessage(content="COMPLETE")] + + monkeypatch.setattr("app.services.ralph_service._plan_prd_from_requirement", fake_plan_prd_from_requirement) + monkeypatch.setattr("app.services.ralph_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.ralph_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/ralph/stream", + json={ + "session_id": "ralph-memory", + "workspace_path": str(workspace), + "requirement": "Use my prior context to plan this task.", + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert '"status": "complete"' in body + assert "Earlier chat preference" in captured["planner_memory"] + assert "I will remember this context." in captured["planner_memory"] + + +def test_ralph_writes_result_back_to_session_memory( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Completed Ralph runs should append a result summary to persistent memory.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + monkeypatch.setenv("PI_SESSION_ROOT", str(tmp_path / "pi-sessions")) + + workspace = tmp_path / "workspace" + workspace.mkdir() + client = TestClient(app) + session_id = "ralph-memory-result" + run_dir = _run_dir(workspace, session_id) + + def fake_plan_prd_from_requirement(*, request, run_state, api_key, planner_memory=""): # noqa: ANN001 + return RalphPrd.model_validate( + { + "project": "demo", + "branchName": "ralph/memory-result", + "description": "Write Ralph result to memory", + "userStories": [ + { + "id": "US-001", + "title": "Create hello.txt", + "description": "Create hello.txt and complete the task", + "acceptanceCriteria": ["Task is completed"], + "priority": 1, + "passes": False, + "notes": "", + } + ], + } + ) + + def fake_get_bound_model(*, api_key, model, base_url, timeout): # noqa: ANN001 + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + (workspace / "hello.txt").write_text("hello\n", encoding="utf-8") + payload = json.loads((run_dir / "prd.json").read_text(encoding="utf-8")) + payload["userStories"][0]["passes"] = True + (run_dir / "prd.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + (run_dir / "progress.txt").write_text( + "# Ralph Progress Log\nStarted: now\n---\n## 2026-03-12T19:10:00 - US-001\n- Created hello.txt.\n---\n", + encoding="utf-8", + ) + return [*messages, AIMessage(content="COMPLETE")] + + monkeypatch.setattr("app.services.ralph_service._plan_prd_from_requirement", fake_plan_prd_from_requirement) + monkeypatch.setattr("app.services.ralph_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.ralph_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/ralph/stream", + json={ + "session_id": session_id, + "workspace_path": str(workspace), + "requirement": "Create hello.txt for me.", + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert '"status": "complete"' in body + + session_root = Path(str(tmp_path / "pi-sessions")) + session_files = list(session_root.rglob(f"*{session_id}.jsonl")) + assert len(session_files) == 1 + + reopened = SessionManager.open(session_files[0]) + context = reopened.build_session_context() + rendered = [str(message.content) for message in context] + + assert "Create hello.txt for me." in rendered + assert any("Ralph task result" in item for item in rendered) + assert any("Status: complete" in item for item in rendered) diff --git a/app/agentland-agent/tests/test_skills.py b/app/agentland-agent/tests/test_skills.py new file mode 100644 index 0000000..c6cea7f --- /dev/null +++ b/app/agentland-agent/tests/test_skills.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +"""deepagents skills integration tests.""" + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + +pytest.importorskip("deepagents") + +from app.main import app +from app.models.ralph import RalphPrd +from app.services.skills_service import build_skills_prompt + + +def _run_dir(workspace: Path, session_id: str, run_id: str = "run-0001") -> Path: + return workspace / ".ralph" / session_id / run_id + + +def test_build_skills_prompt_lists_project_skill(tmp_path: Path) -> None: + """Project skills should be exposed in the injected system prompt.""" + + skill_dir = tmp_path / ".deepagents" / "skills" / "web-research" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: web-research +description: Use this skill when the user asks for structured web research. +--- +# Web research skill +Read this skill before researching. +""", + encoding="utf-8", + ) + + prompt = build_skills_prompt(tmp_path) + + assert "Skills System" in prompt + assert "web-research" in prompt + assert "structured web research" in prompt + assert str((skill_dir / "SKILL.md").resolve()) in prompt + + +def test_chat_stream_injects_skill_prompt(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Chat branch should inject deepagents skill metadata into the system prompt.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + workspace = tmp_path / "workspace" + skill_dir = workspace / ".deepagents" / "skills" / "repo-audit" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: repo-audit +description: Use this skill when auditing repository changes. +--- +# Repo audit skill +Follow the audit checklist. +""", + encoding="utf-8", + ) + + client = TestClient(app) + + def fake_route_prompt(*, messages, api_key: str, model: str, base_url: str | None, timeout: float) -> str: # noqa: ANN001 + return "chat" + + def fake_get_bound_model(*, api_key: str, model: str, base_url: str | None, timeout: float) -> object: + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + assert isinstance(messages[0], SystemMessage) + rendered = str(messages[0].content) + assert "Skills System" in rendered + assert "repo-audit" in rendered + reply = AIMessage(content="done") + if cfg.hooks.on_assistant is not None: + cfg.hooks.on_assistant(reply) + return [*messages, reply] + + monkeypatch.setattr("app.services.chat_service.route_prompt", fake_route_prompt) + monkeypatch.setattr("app.services.chat_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.chat_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/chat/stream", + json={ + "session_id": "skill-chat", + "workspace_path": str(workspace), + "message": "帮我看一下仓库变更", + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert '"mode": "chat"' in body + + +def test_ralph_stream_injects_skill_prompt(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Ralph iteration prompt should include deepagents skill metadata.""" + + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + skill_dir = tmp_path / ".deepagents" / "skills" / "migration" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + """--- +name: migration +description: Use this skill when planning or applying schema migrations. +--- +# Migration skill +Follow the migration checklist. +""", + encoding="utf-8", + ) + + client = TestClient(app) + + def fake_plan_prd_from_requirement(*, request, run_state, api_key): # noqa: ANN001 + return RalphPrd.model_validate( + { + "project": "demo", + "branchName": "ralph/migration-check", + "description": "Check migration skill prompt", + "userStories": [ + { + "id": "US-001", + "title": "Check migration skill prompt", + "description": "Ensure the prompt includes the migration skill", + "acceptanceCriteria": ["Prompt contains migration skill metadata"], + "priority": 1, + "passes": False, + "notes": "", + } + ], + } + ) + + def fake_get_bound_model(*, api_key: str, model: str, base_url: str | None, timeout: float) -> object: + return object() + + def fake_run_agent(messages, cfg): # noqa: ANN001 + assert isinstance(messages[0], SystemMessage) + rendered = str(messages[0].content) + assert "Skills System" in rendered + assert "migration" in rendered + run_dir = _run_dir(tmp_path, "skill-ralph") + payload = RalphPrd.model_validate_json((run_dir / "prd.json").read_text(encoding="utf-8")).model_dump(mode="json") + payload["userStories"][0]["passes"] = True + (run_dir / "prd.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return [*messages, AIMessage(content="COMPLETE")] + + monkeypatch.setattr("app.services.ralph_service._plan_prd_from_requirement", fake_plan_prd_from_requirement) + monkeypatch.setattr("app.services.ralph_service._get_bound_model", fake_get_bound_model) + monkeypatch.setattr("app.services.ralph_service.run_agent", fake_run_agent) + + with client.stream( + "POST", + "/v1/ralph/stream", + json={ + "session_id": "skill-ralph", + "workspace_path": str(tmp_path), + "requirement": "Check the migration skill prompt.", + }, + ) as response: + body = "".join(response.iter_text()) + + assert response.status_code == 200 + assert '"status": "complete"' in body diff --git a/app/be/.gitignore b/app/be/.gitignore new file mode 100644 index 0000000..1f24b37 --- /dev/null +++ b/app/be/.gitignore @@ -0,0 +1 @@ +configs/config.yaml diff --git a/app/be/cmd/main.go b/app/be/cmd/main.go new file mode 100644 index 0000000..39a52d5 --- /dev/null +++ b/app/be/cmd/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "flag" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/Fl0rencess720/agentland/app/be/configs" + "go.uber.org/zap" +) + +func init() { + flag.Parse() + configs.Init() + logger, err := zap.NewProduction() + if err != nil { + panic(err) + } + zap.ReplaceGlobals(logger) +} + +func main() { + app := wireApp() + + go func() { + if err := app.HTTPServer.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + zap.L().Error("HTTP Server ListenAndServe", zap.Error(err)) + panic(err) + } + }() + + closeServers(app.HTTPServer.Server) +} + +func closeServers(servers ...*http.Server) { + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + zap.L().Info("Shutdown Servers ...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + for _, srv := range servers { + if err := srv.Shutdown(ctx); err != nil { + zap.L().Error("Server forced to shutdown", zap.Error(err)) + continue + } + zap.L().Info("Server shutdown successfully", zap.String("addr", srv.Addr)) + } + + zap.L().Info("All servers exiting") +} diff --git a/app/be/cmd/wire.go b/app/be/cmd/wire.go new file mode 100644 index 0000000..d900677 --- /dev/null +++ b/app/be/cmd/wire.go @@ -0,0 +1,29 @@ +//go:build wireinject +// +build wireinject + +package main + +import ( + "github.com/google/wire" + + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/data" + "github.com/Fl0rencess720/agentland/app/be/internal/service" +) + +type App struct { + HTTPServer *service.HTTPServer +} + +func NewApp(httpServer *service.HTTPServer) *App { + return &App{HTTPServer: httpServer} +} + +func wireApp() *App { + panic(wire.Build( + NewApp, + service.ProviderSet, + biz.ProviderSet, + data.ProviderSet, + )) +} diff --git a/app/be/cmd/wire_gen.go b/app/be/cmd/wire_gen.go new file mode 100644 index 0000000..74e6261 --- /dev/null +++ b/app/be/cmd/wire_gen.go @@ -0,0 +1,53 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject + +package main + +import ( + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/data" + "github.com/Fl0rencess720/agentland/app/be/internal/service" + "github.com/Fl0rencess720/agentland/app/be/internal/service/auth" + "github.com/Fl0rencess720/agentland/app/be/internal/service/deployment" + "github.com/Fl0rencess720/agentland/app/be/internal/service/file" + "github.com/Fl0rencess720/agentland/app/be/internal/service/job" + "github.com/Fl0rencess720/agentland/app/be/internal/service/middlewares" + "github.com/Fl0rencess720/agentland/app/be/internal/service/project" +) + +func wireApp() *App { + ipRateLimiter := middlewares.NewDefaultIPRateLimiter() + userRepo := data.NewUserRepo() + authRepo := data.NewAuthRepo() + oauthStateStore := data.NewOAuthStateStore() + githubOAuthClient := data.NewGitHubOAuthClient() + authUseCase := biz.NewAuthUsecase(userRepo, authRepo, oauthStateStore, githubOAuthClient) + authHandler := auth.NewAuthHandler(authUseCase) + projectRepo := data.NewProjectRepo() + jobRepo := data.NewJobRepo() + agentlandGateway := data.NewAgentlandGatewayClient() + projectUseCase := biz.NewProjectUsecase(projectRepo, jobRepo, agentlandGateway) + projectHandler := project.NewProjectHandler(projectUseCase) + jobUseCase := biz.NewJobUsecase(jobRepo) + jobHandler := job.NewJobHandler(jobUseCase) + deploymentRepo := data.NewDeploymentRepo() + deploymentUseCase := biz.NewDeploymentUsecase(deploymentRepo) + deploymentHandler := deployment.NewDeploymentHandler(deploymentUseCase) + fileRepo := data.NewFileRepo() + fileUseCase := biz.NewFileUsecase(fileRepo) + fileHandler := file.NewFileHandler(fileUseCase) + httpServer := service.NewHTTPServer(ipRateLimiter, authHandler, projectHandler, jobHandler, deploymentHandler, fileHandler) + app := NewApp(httpServer) + return app +} + +type App struct { + HTTPServer *service.HTTPServer +} + +func NewApp(httpServer *service.HTTPServer) *App { + return &App{HTTPServer: httpServer} +} diff --git a/app/be/configs/config.go b/app/be/configs/config.go new file mode 100644 index 0000000..2f35a62 --- /dev/null +++ b/app/be/configs/config.go @@ -0,0 +1,45 @@ +package configs + +import ( + "strings" + "time" + + "github.com/spf13/viper" +) + +func Init() { + viper.SetConfigType("yaml") + viper.SetConfigName("config") + viper.AddConfigPath("configs") + + viper.SetDefault("server.http.name", "agentland.app.be") + viper.SetDefault("server.http.addr", ":18081") + viper.SetDefault("redis.addr", "127.0.0.1:6379") + viper.SetDefault("redis.password", "") + viper.SetDefault("redis.db", 0) + viper.SetDefault("database.url", "") + viper.SetDefault("agentland-gateway.url", "http://127.0.0.1:18080") + viper.SetDefault("auth.github.client_id", "") + viper.SetDefault("auth.github.client_secret", "") + viper.SetDefault("auth.github.redirect_uri_allowlist", []string{}) + viper.SetDefault("auth.github.scopes", []string{"read:user", "user:email"}) + viper.SetDefault("auth.github.authorize_url", "https://github.com/login/oauth/authorize") + viper.SetDefault("auth.github.token_url", "https://github.com/login/oauth/access_token") + viper.SetDefault("auth.github.api_base_url", "https://api.github.com") + viper.SetDefault("auth.jwt.issuer", "agentland-app-be") + viper.SetDefault("auth.jwt.audience", "agentland-app") + viper.SetDefault("auth.jwt.private_key_path", "") + viper.SetDefault("auth.jwt.public_key_path", "") + viper.SetDefault("auth.access_ttl", 15*time.Minute) + viper.SetDefault("auth.refresh_ttl", 30*24*time.Hour) + viper.SetDefault("auth.oauth_state_ttl", 10*time.Minute) + viper.SetDefault("auth.user.default_plan", "free") + + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + _ = viper.ReadInConfig() +} + +func GetServiceName() string { + return viper.GetString("server.http.name") +} diff --git a/app/be/internal/biz/auth.go b/app/be/internal/biz/auth.go new file mode 100644 index 0000000..224f2de --- /dev/null +++ b/app/be/internal/biz/auth.go @@ -0,0 +1,350 @@ +package biz + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "sync" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/autherr" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/jwtc" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/token" + "github.com/spf13/viper" + "go.uber.org/zap" +) + +type UserRepo interface { + UpsertGitHubUser(ctx context.Context, profile *models.GitHubUserProfile) (*models.User, error) + GetUserByID(ctx context.Context, userID string) (*models.User, error) +} + +type AuthRepo interface { + CreateSession(ctx context.Context, input *models.CreateSessionInput) (*models.AuthSession, error) + RotateRefreshToken(ctx context.Context, input *models.RotateRefreshTokenInput) (*models.RotateRefreshTokenResult, error) + RevokeSessionByRefreshToken(ctx context.Context, input *models.RevokeSessionByTokenInput) error +} + +type OAuthStateStore interface { + SaveGitHubState(ctx context.Context, state *models.GitHubOAuthState, ttl time.Duration) error + ConsumeGitHubState(ctx context.Context, state string) (*models.GitHubOAuthState, error) +} + +type GitHubOAuthClient interface { + ExchangeCode(ctx context.Context, code, redirectURI string) (string, error) + FetchUser(ctx context.Context, accessToken string) (*models.GitHubUserProfile, error) + FetchPrimaryVerifiedEmail(ctx context.Context, accessToken string) (string, error) +} + +type authConfig struct { + ClientID string + ClientSecret string + AuthorizeURL string + RedirectURIAllowlist []string + Scopes []string + AccessTTL time.Duration + RefreshTTL time.Duration + OAuthStateTTL time.Duration + JWTIssuer string + JWTAudience string + JWTPrivateKeyPath string + JWTPublicKeyPath string + DefaultPlan string +} + +type authUseCase struct { + userRepo UserRepo + authRepo AuthRepo + stateStore OAuthStateStore + githubClient GitHubOAuthClient + cfg authConfig + now func() time.Time + jwtOnce sync.Once + jwtManager *jwtc.Manager + jwtErr error +} + +func NewAuthUsecase(userRepo UserRepo, authRepo AuthRepo, stateStore OAuthStateStore, githubClient GitHubOAuthClient) AuthUseCase { + return &authUseCase{ + userRepo: userRepo, + authRepo: authRepo, + stateStore: stateStore, + githubClient: githubClient, + cfg: authConfig{ + ClientID: viper.GetString("auth.github.client_id"), + ClientSecret: viper.GetString("auth.github.client_secret"), + AuthorizeURL: viper.GetString("auth.github.authorize_url"), + RedirectURIAllowlist: getStringSlice("auth.github.redirect_uri_allowlist"), + Scopes: getStringSlice("auth.github.scopes"), + AccessTTL: viper.GetDuration("auth.access_ttl"), + RefreshTTL: viper.GetDuration("auth.refresh_ttl"), + OAuthStateTTL: viper.GetDuration("auth.oauth_state_ttl"), + JWTIssuer: viper.GetString("auth.jwt.issuer"), + JWTAudience: viper.GetString("auth.jwt.audience"), + JWTPrivateKeyPath: viper.GetString("auth.jwt.private_key_path"), + JWTPublicKeyPath: viper.GetString("auth.jwt.public_key_path"), + DefaultPlan: viper.GetString("auth.user.default_plan"), + }, + now: time.Now, + } +} + +func (u *authUseCase) GitHubStart(ctx context.Context, req *models.GitHubStartReq) (*models.GitHubStartResp, *response.APIError) { + redirectURI := strings.TrimSpace(req.RedirectURI) + if !u.isAllowedRedirectURI(redirectURI) { + zap.L().Warn("github start redirect uri not allowed", zap.String("redirect_uri", redirectURI)) + return nil, response.InvalidArgumentError("redirect_uri", "not allowed") + } + state, err := token.NewOpaque("st_") + if err != nil { + zap.L().Error("generate github oauth state failed", zap.Error(err)) + return nil, response.InternalError() + } + if err = u.stateStore.SaveGitHubState(ctx, &models.GitHubOAuthState{ + State: state, + RedirectURI: redirectURI, + IssuedAt: u.now().UTC(), + }, u.cfg.OAuthStateTTL); err != nil { + zap.L().Error("save github oauth state failed", zap.Error(err), zap.String("redirect_uri", redirectURI)) + return nil, response.InternalError() + } + + return &models.GitHubStartResp{ + AuthorizeURL: u.buildAuthorizeURL(redirectURI, state), + State: state, + }, nil +} + +func (u *authUseCase) GitHubCallback(ctx context.Context, req *models.GitHubCallbackReq, userAgent, ip string) (*models.GitHubCallbackResp, *response.APIError) { + stateRecord, err := u.stateStore.ConsumeGitHubState(ctx, strings.TrimSpace(req.State)) + if err != nil { + zap.L().Warn("consume github oauth state failed", zap.Error(err)) + return nil, u.apiError(err) + } + + githubAccessToken, err := u.githubClient.ExchangeCode(ctx, strings.TrimSpace(req.Code), stateRecord.RedirectURI) + if err != nil { + zap.L().Warn("exchange github code failed", zap.Error(err), zap.String("redirect_uri", stateRecord.RedirectURI)) + return nil, u.apiError(err) + } + + githubUser, err := u.githubClient.FetchUser(ctx, githubAccessToken) + if err != nil { + zap.L().Error("fetch github user failed", zap.Error(err)) + return nil, u.apiError(err) + } + if strings.TrimSpace(githubUser.Email) == "" { + email, emailErr := u.githubClient.FetchPrimaryVerifiedEmail(ctx, githubAccessToken) + if emailErr != nil { + zap.L().Error("fetch github primary email failed", zap.Error(emailErr), zap.String("github_user_id", githubUser.ID), zap.String("github_login", githubUser.Login)) + return nil, u.apiError(emailErr) + } + githubUser.Email = email + } + + user, err := u.userRepo.UpsertGitHubUser(ctx, githubUser) + if err != nil { + zap.L().Error("upsert github user failed", zap.Error(err), zap.String("github_user_id", githubUser.ID), zap.String("github_login", githubUser.Login), zap.String("email", githubUser.Email)) + return nil, response.InternalError() + } + + rawRefreshToken, err := token.NewOpaque("rt_") + if err != nil { + zap.L().Error("generate refresh token failed", zap.Error(err), zap.String("user_id", user.ID)) + return nil, response.InternalError() + } + issuedAt := u.now().UTC() + sessionID := token.NewID("sess") + refreshFamilyID := token.NewID("rtf") + refreshTokenID := token.NewID("rtt") + session, err := u.authRepo.CreateSession(ctx, &models.CreateSessionInput{ + UserID: user.ID, + SessionID: sessionID, + RefreshFamilyID: refreshFamilyID, + RefreshTokenID: refreshTokenID, + RefreshTokenHash: token.Hash(rawRefreshToken), + UserAgent: userAgent, + IP: ip, + Now: issuedAt, + RefreshExpiresAt: issuedAt.Add(u.cfg.RefreshTTL), + SessionExpiresAt: issuedAt.Add(u.cfg.RefreshTTL), + }) + if err != nil { + zap.L().Error("create auth session failed", zap.Error(err), zap.String("user_id", user.ID), zap.String("session_id", sessionID), zap.String("refresh_family_id", refreshFamilyID)) + return nil, response.InternalError() + } + + jwtManager, apiErr := u.getJWTManager() + if apiErr != nil { + return nil, apiErr + } + accessToken, accessExpiresAt, err := jwtManager.SignAccessToken(user.ID, session.ID, issuedAt) + if err != nil { + zap.L().Error("sign access token failed", zap.Error(err), zap.String("user_id", user.ID), zap.String("session_id", session.ID)) + return nil, response.InternalError() + } + + return &models.GitHubCallbackResp{ + User: models.UserProfile{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + AvatarURL: user.AvatarURL, + GitHubID: githubUser.ID, + GitHubLogin: githubUser.Login, + Plan: user.Plan, + }, + AccessToken: accessToken, + RefreshToken: rawRefreshToken, + ExpiresIn: int(time.Until(accessExpiresAt).Seconds()), + }, nil +} + +func (u *authUseCase) Refresh(ctx context.Context, req *models.RefreshTokenReq, userAgent, ip string) (*models.RefreshTokenResp, *response.APIError) { + newRawRefreshToken, err := token.NewOpaque("rt_") + if err != nil { + return nil, response.InternalError() + } + issuedAt := u.now().UTC() + result, err := u.authRepo.RotateRefreshToken(ctx, &models.RotateRefreshTokenInput{ + CurrentTokenHash: token.Hash(strings.TrimSpace(req.RefreshToken)), + NewTokenID: token.NewID("rtt"), + NewTokenHash: token.Hash(newRawRefreshToken), + Now: issuedAt, + NewExpiresAt: issuedAt.Add(u.cfg.RefreshTTL), + }) + if err != nil { + return nil, u.apiError(err) + } + _ = userAgent + _ = ip + + jwtManager, apiErr := u.getJWTManager() + if apiErr != nil { + return nil, apiErr + } + accessToken, accessExpiresAt, err := jwtManager.SignAccessToken(result.UserID, result.SessionID, issuedAt) + if err != nil { + return nil, response.InternalError() + } + + return &models.RefreshTokenResp{ + AccessToken: accessToken, + RefreshToken: newRawRefreshToken, + ExpiresIn: int(time.Until(accessExpiresAt).Seconds()), + }, nil +} + +func (u *authUseCase) Me(ctx context.Context, principal models.AuthPrincipal) (*models.CurrentUserResp, *response.APIError) { + user, err := u.userRepo.GetUserByID(ctx, principal.UserID) + if err != nil { + return nil, u.apiError(err) + } + return &models.CurrentUserResp{ + ID: user.ID, + Email: user.Email, + Name: user.Name, + AvatarURL: user.AvatarURL, + Plan: user.Plan, + }, nil +} + +func (u *authUseCase) Logout(ctx context.Context, principal models.AuthPrincipal, req *models.LogoutReq) (*models.LogoutResp, *response.APIError) { + err := u.authRepo.RevokeSessionByRefreshToken(ctx, &models.RevokeSessionByTokenInput{ + RefreshTokenHash: token.Hash(strings.TrimSpace(req.RefreshToken)), + UserID: principal.UserID, + SessionID: principal.SessionID, + Now: u.now().UTC(), + }) + if err != nil { + return nil, u.apiError(err) + } + return &models.LogoutResp{Success: true}, nil +} + +func (u *authUseCase) buildAuthorizeURL(redirectURI, state string) string { + values := url.Values{} + values.Set("client_id", u.cfg.ClientID) + values.Set("redirect_uri", redirectURI) + values.Set("scope", strings.Join(u.cfg.Scopes, " ")) + values.Set("state", state) + return u.cfg.AuthorizeURL + "?" + values.Encode() +} + +func (u *authUseCase) isAllowedRedirectURI(redirectURI string) bool { + if redirectURI == "" { + return false + } + for _, item := range u.cfg.RedirectURIAllowlist { + if redirectURI == strings.TrimSpace(item) { + return true + } + } + return false +} + +func (u *authUseCase) getJWTManager() (*jwtc.Manager, *response.APIError) { + u.jwtOnce.Do(func() { + u.jwtManager, u.jwtErr = jwtc.NewManager(jwtc.Config{ + PrivateKeyPath: u.cfg.JWTPrivateKeyPath, + PublicKeyPath: u.cfg.JWTPublicKeyPath, + Issuer: u.cfg.JWTIssuer, + Audience: u.cfg.JWTAudience, + TTL: u.cfg.AccessTTL, + }) + }) + if u.jwtErr != nil { + zap.L().Error("init jwt manager failed", zap.Error(u.jwtErr), zap.String("private_key_path", u.cfg.JWTPrivateKeyPath), zap.String("public_key_path", u.cfg.JWTPublicKeyPath)) + return nil, response.InternalError() + } + return u.jwtManager, nil +} + +func (u *authUseCase) apiError(err error) *response.APIError { + switch { + case err == nil: + return nil + case errors.Is(err, autherr.ErrRedirectURINotAllow): + return response.InvalidArgumentError("redirect_uri", "not allowed") + case errors.Is(err, autherr.ErrOAuthStateNotFound): + return response.UnauthorizedError() + case errors.Is(err, autherr.ErrUserNotFound): + return response.UnauthorizedError() + case errors.Is(err, autherr.ErrUnauthorized): + return response.UnauthorizedError() + case errors.Is(err, autherr.ErrRefreshReplay): + return response.UnauthorizedError() + case errors.Is(err, autherr.ErrRefreshExpired): + return response.UnauthorizedError() + case errors.Is(err, autherr.ErrSessionRevoked): + return response.UnauthorizedError() + default: + return response.InternalError() + } +} + +func getStringSlice(key string) []string { + values := viper.GetStringSlice(key) + if len(values) > 0 { + return values + } + raw := strings.TrimSpace(viper.GetString(key)) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + items := make([]string, 0, len(parts)) + for _, item := range parts { + trimmed := strings.TrimSpace(item) + if trimmed != "" { + items = append(items, trimmed) + } + } + return items +} + +var _ = fmt.Sprintf diff --git a/app/be/internal/biz/auth_test.go b/app/be/internal/biz/auth_test.go new file mode 100644 index 0000000..c8937dc --- /dev/null +++ b/app/be/internal/biz/auth_test.go @@ -0,0 +1,145 @@ +package biz + +import ( + "context" + "testing" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/autherr" + securetoken "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/token" + "github.com/Fl0rencess720/agentland/pkg/common/testutil" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +type fakeUserRepo struct { + user *models.User + last *models.GitHubUserProfile +} + +func (f *fakeUserRepo) UpsertGitHubUser(_ context.Context, profile *models.GitHubUserProfile) (*models.User, error) { + f.last = profile + if f.user == nil { + f.user = &models.User{ID: "u_123", Email: profile.Email, Name: profile.Name, AvatarURL: profile.AvatarURL, Plan: "free"} + } + f.user.Email = profile.Email + f.user.Name = profile.Name + return f.user, nil +} + +func (f *fakeUserRepo) GetUserByID(_ context.Context, userID string) (*models.User, error) { + if f.user == nil || f.user.ID != userID { + return nil, autherr.ErrUserNotFound + } + return f.user, nil +} + +type fakeAuthRepo struct { + rotated bool + revoked bool +} + +func (f *fakeAuthRepo) CreateSession(_ context.Context, input *models.CreateSessionInput) (*models.AuthSession, error) { + return &models.AuthSession{ID: input.SessionID, UserID: input.UserID}, nil +} + +func (f *fakeAuthRepo) RotateRefreshToken(_ context.Context, input *models.RotateRefreshTokenInput) (*models.RotateRefreshTokenResult, error) { + if input.CurrentTokenHash == securetoken.Hash("replay") { + return nil, autherr.ErrRefreshReplay + } + f.rotated = true + return &models.RotateRefreshTokenResult{UserID: "u_123", SessionID: "sess_123", FamilyID: "rtf_123"}, nil +} + +func (f *fakeAuthRepo) RevokeSessionByRefreshToken(_ context.Context, _ *models.RevokeSessionByTokenInput) error { + f.revoked = true + return nil +} + +type fakeStateStore struct { + stored map[string]*models.GitHubOAuthState +} + +func (f *fakeStateStore) SaveGitHubState(_ context.Context, state *models.GitHubOAuthState, _ time.Duration) error { + if f.stored == nil { + f.stored = map[string]*models.GitHubOAuthState{} + } + f.stored[state.State] = state + return nil +} + +func (f *fakeStateStore) ConsumeGitHubState(_ context.Context, state string) (*models.GitHubOAuthState, error) { + item, ok := f.stored[state] + if !ok { + return nil, autherr.ErrOAuthStateNotFound + } + delete(f.stored, state) + return item, nil +} + +type fakeGitHubClient struct{} + +func (f *fakeGitHubClient) ExchangeCode(_ context.Context, code, redirectURI string) (string, error) { + if code == "bad" || redirectURI == "" { + return "", autherr.ErrUnauthorized + } + return "gh_token", nil +} + +func (f *fakeGitHubClient) FetchUser(_ context.Context, _ string) (*models.GitHubUserProfile, error) { + return &models.GitHubUserProfile{ID: "123456", Login: "alice-dev", Name: "Alice", AvatarURL: "https://avatar.example.com/1.png"}, nil +} + +func (f *fakeGitHubClient) FetchPrimaryVerifiedEmail(_ context.Context, _ string) (string, error) { + return "user@company.com", nil +} + +func TestAuthUseCaseGitHubStartAndCallback(t *testing.T) { + privatePath, publicPath, err := testutil.WriteTestRSAKeys(t.TempDir()) + require.NoError(t, err) + viper.Set("auth.github.client_id", "client_123") + viper.Set("auth.github.redirect_uri_allowlist", []string{"https://app.example.com/auth/github/callback"}) + viper.Set("auth.github.scopes", []string{"read:user", "user:email"}) + viper.Set("auth.github.authorize_url", "https://github.com/login/oauth/authorize") + viper.Set("auth.oauth_state_ttl", 10*time.Minute) + viper.Set("auth.access_ttl", 15*time.Minute) + viper.Set("auth.refresh_ttl", 30*24*time.Hour) + viper.Set("auth.jwt.issuer", "agentland-app-be") + viper.Set("auth.jwt.audience", "agentland-app") + viper.Set("auth.jwt.private_key_path", privatePath) + viper.Set("auth.jwt.public_key_path", publicPath) + viper.Set("auth.user.default_plan", "free") + + stateStore := &fakeStateStore{} + userRepo := &fakeUserRepo{} + authRepo := &fakeAuthRepo{} + useCase := NewAuthUsecase(userRepo, authRepo, stateStore, &fakeGitHubClient{}) + + startResp, apiErr := useCase.GitHubStart(context.Background(), &models.GitHubStartReq{RedirectURI: "https://app.example.com/auth/github/callback"}) + require.Nil(t, apiErr) + require.NotEmpty(t, startResp.State) + require.Contains(t, startResp.AuthorizeURL, "client_id=client_123") + + callbackResp, apiErr := useCase.GitHubCallback(context.Background(), &models.GitHubCallbackReq{Code: "ok", State: startResp.State}, "ua", "127.0.0.1") + require.Nil(t, apiErr) + require.Equal(t, "user@company.com", callbackResp.User.Email) + require.NotEmpty(t, callbackResp.AccessToken) + require.NotEmpty(t, callbackResp.RefreshToken) +} + +func TestAuthUseCaseRefreshReplay(t *testing.T) { + privatePath, publicPath, err := testutil.WriteTestRSAKeys(t.TempDir()) + require.NoError(t, err) + viper.Set("auth.access_ttl", 15*time.Minute) + viper.Set("auth.refresh_ttl", 30*24*time.Hour) + viper.Set("auth.jwt.issuer", "agentland-app-be") + viper.Set("auth.jwt.audience", "agentland-app") + viper.Set("auth.jwt.private_key_path", privatePath) + viper.Set("auth.jwt.public_key_path", publicPath) + + useCase := NewAuthUsecase(&fakeUserRepo{user: &models.User{ID: "u_123"}}, &fakeAuthRepo{}, &fakeStateStore{}, &fakeGitHubClient{}) + _, apiErr := useCase.Refresh(context.Background(), &models.RefreshTokenReq{RefreshToken: "replay"}, "ua", "127.0.0.1") + require.NotNil(t, apiErr) + require.Equal(t, 401, apiErr.StatusCode) +} diff --git a/app/be/internal/biz/biz.go b/app/be/internal/biz/biz.go new file mode 100644 index 0000000..75b9ffa --- /dev/null +++ b/app/be/internal/biz/biz.go @@ -0,0 +1,3 @@ +package biz + +var ProviderSet = struct{}{} diff --git a/app/be/internal/biz/deployment.go b/app/be/internal/biz/deployment.go new file mode 100644 index 0000000..92e6487 --- /dev/null +++ b/app/be/internal/biz/deployment.go @@ -0,0 +1,11 @@ +package biz + +type DeploymentRepo interface{} + +type deploymentUseCase struct { + repo DeploymentRepo +} + +func NewDeploymentUsecase(repo DeploymentRepo) DeploymentUseCase { + return &deploymentUseCase{repo: repo} +} diff --git a/app/be/internal/biz/file.go b/app/be/internal/biz/file.go new file mode 100644 index 0000000..6220a72 --- /dev/null +++ b/app/be/internal/biz/file.go @@ -0,0 +1,11 @@ +package biz + +type FileRepo interface{} + +type fileUseCase struct { + repo FileRepo +} + +func NewFileUsecase(repo FileRepo) FileUseCase { + return &fileUseCase{repo: repo} +} diff --git a/app/be/internal/biz/interface.go b/app/be/internal/biz/interface.go new file mode 100644 index 0000000..b0f561c --- /dev/null +++ b/app/be/internal/biz/interface.go @@ -0,0 +1,42 @@ +package biz + +import ( + "context" + + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" +) + +type AuthUseCase interface { + GitHubStart(ctx context.Context, req *models.GitHubStartReq) (*models.GitHubStartResp, *response.APIError) + GitHubCallback(ctx context.Context, req *models.GitHubCallbackReq, userAgent, ip string) (*models.GitHubCallbackResp, *response.APIError) + Refresh(ctx context.Context, req *models.RefreshTokenReq, userAgent, ip string) (*models.RefreshTokenResp, *response.APIError) + Me(ctx context.Context, principal models.AuthPrincipal) (*models.CurrentUserResp, *response.APIError) + Logout(ctx context.Context, principal models.AuthPrincipal, req *models.LogoutReq) (*models.LogoutResp, *response.APIError) +} + +type ProjectUseCase interface { + List(ctx context.Context, principal models.AuthPrincipal, req *models.ProjectListReq) (*models.ProjectListResp, *response.APIError) + Create(ctx context.Context, principal models.AuthPrincipal, req *models.ProjectCreateReq) (*models.ProjectCreateResp, *response.APIError) + Detail(ctx context.Context, principal models.AuthPrincipal, projectID string) (*models.ProjectDetailResp, *response.APIError) + Update(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.ProjectUpdateReq) (*models.ProjectUpdateResp, *response.APIError) + Delete(ctx context.Context, principal models.AuthPrincipal, projectID string) (*models.ProjectDeleteResp, *response.APIError) + Usage(ctx context.Context, principal models.AuthPrincipal) (*models.ProjectUsageResp, *response.APIError) + CreateGeneration(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.GenerationCreateReq) (*models.GenerationCreateResp, *response.APIError) + ListMessages(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.ChatMessagesReq) (*models.ChatMessagesResp, *response.APIError) + CreateMessage(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.ChatMessageCreateReq, onDelta func(string) error) (*models.ChatMessageStreamDoneResp, error) + FileTree(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.FileTreeReq) (*models.FileTreeResp, *response.APIError) + FileContent(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.FileContentReq) (*models.FileContentResp, *response.APIError) + Download(ctx context.Context, principal models.AuthPrincipal, projectID string) (*models.WorkspaceArchive, *response.APIError) + StartPreview(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.PreviewStartReq) (*models.PreviewStartResp, *response.APIError) + PreviewStatus(ctx context.Context, principal models.AuthPrincipal, projectID string) (*models.PreviewStatusResp, *response.APIError) +} + +type UserUseCase interface{} + +type JobUseCase interface { + Detail(ctx context.Context, principal models.AuthPrincipal, jobID string) (*models.JobStatusResp, *response.APIError) +} + +type DeploymentUseCase interface{} +type FileUseCase interface{} diff --git a/app/be/internal/biz/job.go b/app/be/internal/biz/job.go new file mode 100644 index 0000000..0530a63 --- /dev/null +++ b/app/be/internal/biz/job.go @@ -0,0 +1,59 @@ +package biz + +import ( + "context" + "errors" + "strings" + + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/autherr" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" +) + +type JobRepo interface { + CreateJob(ctx context.Context, input *models.CreateJobInput) (*models.Job, error) + GetJobByID(ctx context.Context, ownerID, jobID string) (*models.Job, error) + GetLatestProjectRuntime(ctx context.Context, ownerID, projectID string) (*models.Job, error) + UpdateJob(ctx context.Context, input *models.UpdateJobInput) error +} + +type jobUseCase struct { + repo JobRepo +} + +func NewJobUsecase(repo JobRepo) JobUseCase { + return &jobUseCase{repo: repo} +} + +func (u *jobUseCase) Detail(ctx context.Context, principal models.AuthPrincipal, jobID string) (*models.JobStatusResp, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + job, err := u.repo.GetJobByID(ctx, principal.UserID, strings.TrimSpace(jobID)) + if err != nil { + return nil, u.apiError(err) + } + logs := job.Logs + if logs == nil { + logs = []string{} + } + return &models.JobStatusResp{ + JobID: job.ID, + Type: job.Type, + Status: job.Status, + Progress: job.Progress, + Logs: logs, + Result: job.Result, + }, nil +} + +func (u *jobUseCase) apiError(err error) *response.APIError { + switch { + case err == nil: + return nil + case errors.Is(err, autherr.ErrJobNotFound): + return response.NotFoundError() + default: + return response.InternalError() + } +} diff --git a/app/be/internal/biz/job_test.go b/app/be/internal/biz/job_test.go new file mode 100644 index 0000000..286c927 --- /dev/null +++ b/app/be/internal/biz/job_test.go @@ -0,0 +1,33 @@ +package biz + +import ( + "context" + "testing" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/stretchr/testify/require" +) + +func TestJobUseCaseDetail(t *testing.T) { + repo := &fakeJobRepo{jobs: map[string]*models.Job{ + "job_1": { + ID: "job_1", + OwnerID: "u_123", + ProjectID: "p_1", + Type: "APP_GENERATION", + Status: "RUNNING", + Progress: 42, + Logs: []string{"Sandbox ready"}, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + }} + useCase := NewJobUsecase(repo) + + resp, apiErr := useCase.Detail(context.Background(), models.AuthPrincipal{UserID: "u_123"}, "job_1") + require.Nil(t, apiErr) + require.Equal(t, "job_1", resp.JobID) + require.Equal(t, "APP_GENERATION", resp.Type) + require.Equal(t, 42, resp.Progress) +} diff --git a/app/be/internal/biz/project.go b/app/be/internal/biz/project.go new file mode 100644 index 0000000..f3274d9 --- /dev/null +++ b/app/be/internal/biz/project.go @@ -0,0 +1,1006 @@ +package biz + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/autherr" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/token" + "go.uber.org/zap" +) + +const ( + defaultProjectPageSize = 20 + maxProjectPageSize = 100 + projectStatusDraft = "DRAFT" + projectStatusBuilding = "BUILDING" + generationJobType = "APP_GENERATION" + generationJobQueued = "QUEUED" + generationJobStarting = "STARTING" + generationJobRunning = "RUNNING" + generationJobSuccess = "SUCCESS" + generationJobFailed = "FAILED" + defaultGenerationWorkspace = "/workspace" + defaultGenerationIterations = 10 + generationExecutionTimeout = 15 * time.Minute + maxPersistedGenerationJobLogs = 100 +) + +var planProjectLimits = map[string]int{ + "free": 12, + "pro": 100, + "enterprise": 1000, +} + +type ProjectRepo interface { + CreateProject(ctx context.Context, input *models.CreateProjectInput) (*models.Project, error) + ListProjects(ctx context.Context, filter *models.ProjectListFilter) ([]*models.Project, int, error) + GetProjectByID(ctx context.Context, ownerID, projectID string) (*models.Project, error) + GetProjectAndTouch(ctx context.Context, ownerID, projectID string, now time.Time) (*models.Project, error) + UpdateProject(ctx context.Context, input *models.UpdateProjectInput) (*models.Project, error) + UpdateProjectStatus(ctx context.Context, ownerID, projectID, status string, now time.Time) error + SoftDeleteProject(ctx context.Context, ownerID, projectID string, now time.Time) error + CountActiveProjectsByOwner(ctx context.Context, ownerID string) (int, error) + GetUserPlan(ctx context.Context, userID string) (string, error) + GetProjectChatSession(ctx context.Context, ownerID, projectID string) (*models.ProjectChatSession, error) + UpsertProjectChatSession(ctx context.Context, input *models.UpsertProjectChatSessionInput) (*models.ProjectChatSession, error) + ListProjectChatMessages(ctx context.Context, ownerID, projectID, cursor string, limit int) ([]*models.ProjectChatMessage, *string, error) + CreateProjectChatMessage(ctx context.Context, input *models.CreateProjectChatMessageInput) (*models.ProjectChatMessage, error) + UpdateProjectChatMessageContent(ctx context.Context, ownerID, projectID, messageID, content string) error +} + +type AgentlandGateway interface { + EnsureSessionReady(ctx context.Context) (*models.AgentSessionInfo, error) + StreamChat(ctx context.Context, gatewaySessionID string, req *models.AgentChatStreamReq, onEvent func(*models.AgentSSEEvent) error) error + CreatePreview(ctx context.Context, gatewaySessionID string, port int) (*models.GatewayPreviewInfo, error) + GetFSTree(ctx context.Context, gatewaySessionID, targetPath string, depth int) (*models.GatewayFSTreeResp, error) + GetFSFile(ctx context.Context, gatewaySessionID, targetPath, encoding string) (*models.GatewayFSFileResp, error) + CreateExecContext(ctx context.Context, gatewaySessionID, language, cwd string) (*models.GatewayExecContextInfo, error) + ExecuteInContext(ctx context.Context, gatewaySessionID, contextID, code string, timeoutMs int) (*models.GatewayExecutionResult, error) + ProbePort(ctx context.Context, gatewaySessionID string, port int, requestPath string) (int, error) +} + +type projectUseCase struct { + repo ProjectRepo + jobRepo JobRepo + gateway AgentlandGateway + now func() time.Time + runAsync func(func()) + sessionLocks sync.Map +} + +type generationRunState struct { + job models.Job + ownerID string + projectID string + projectName string + prompt string + userMessageID string + assistantMessageID string + routeIntent string + assistantText strings.Builder + done bool +} + +func NewProjectUsecase(repo ProjectRepo, jobRepo JobRepo, gateway AgentlandGateway) ProjectUseCase { + return &projectUseCase{ + repo: repo, + jobRepo: jobRepo, + gateway: gateway, + now: time.Now, + runAsync: func(fn func()) { + go fn() + }, + } +} + +func (u *projectUseCase) List(ctx context.Context, principal models.AuthPrincipal, req *models.ProjectListReq) (*models.ProjectListResp, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + + view := strings.ToLower(strings.TrimSpace(req.View)) + if view == "" { + view = "all" + } + if view != "all" && view != "recent" && view != "shared" { + return nil, response.InvalidArgumentError("view", "unsupported") + } + if view == "shared" { + page, pageSize := normalizePagination(req.Page, req.PageSize) + return &models.ProjectListResp{ + Items: []models.ProjectItem{}, + Pagination: models.Pagination{ + Page: page, + PageSize: pageSize, + Total: 0, + }, + }, nil + } + + sortBy := strings.ToLower(strings.TrimSpace(req.SortBy)) + if sortBy == "" { + sortBy = "updated_at" + } + if sortBy != "updated_at" && sortBy != "created_at" && sortBy != "name" { + return nil, response.InvalidArgumentError("sort_by", "unsupported") + } + + sortOrder := strings.ToLower(strings.TrimSpace(req.SortOrder)) + if sortOrder == "" { + sortOrder = "desc" + } + if sortOrder != "asc" && sortOrder != "desc" { + return nil, response.InvalidArgumentError("sort_order", "unsupported") + } + + page, pageSize := normalizePagination(req.Page, req.PageSize) + projects, total, err := u.repo.ListProjects(ctx, &models.ProjectListFilter{ + OwnerID: principal.UserID, + Keyword: strings.TrimSpace(req.Keyword), + Status: strings.ToUpper(strings.TrimSpace(req.Status)), + SortBy: sortBy, + SortOrder: sortOrder, + Page: page, + PageSize: pageSize, + View: view, + }) + if err != nil { + return nil, u.apiError(err) + } + + items := make([]models.ProjectItem, 0, len(projects)) + for _, item := range projects { + items = append(items, models.ProjectItem{ + ID: item.ID, + Name: item.Name, + Status: item.Status, + ThumbnailURL: item.ThumbnailURL, + CreatedAt: formatProjectTime(item.CreatedAt), + UpdatedAt: formatProjectTime(item.UpdatedAt), + IsShared: false, + }) + } + + return &models.ProjectListResp{ + Items: items, + Pagination: models.Pagination{ + Page: page, + PageSize: pageSize, + Total: total, + }, + }, nil +} + +func (u *projectUseCase) Create(ctx context.Context, principal models.AuthPrincipal, req *models.ProjectCreateReq) (*models.ProjectCreateResp, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + name := strings.TrimSpace(req.Name) + if name == "" { + return nil, response.InvalidArgumentError("name", "required") + } + template := strings.TrimSpace(req.Template) + if template == "" { + return nil, response.InvalidArgumentError("template", "required") + } + + now := u.now().UTC() + project, err := u.repo.CreateProject(ctx, &models.CreateProjectInput{ + ID: token.NewID("p"), + OwnerID: principal.UserID, + Name: name, + Template: template, + Status: projectStatusDraft, + Now: now, + }) + if err != nil { + return nil, u.apiError(err) + } + + return &models.ProjectCreateResp{ + ID: project.ID, + Name: project.Name, + Status: project.Status, + CreatedAt: formatProjectTime(project.CreatedAt), + }, nil +} + +func (u *projectUseCase) Detail(ctx context.Context, principal models.AuthPrincipal, projectID string) (*models.ProjectDetailResp, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + project, err := u.repo.GetProjectAndTouch(ctx, principal.UserID, strings.TrimSpace(projectID), u.now().UTC()) + if err != nil { + return nil, u.apiError(err) + } + return &models.ProjectDetailResp{ + ID: project.ID, + Name: project.Name, + Status: project.Status, + OwnerID: project.OwnerID, + LastOpenedAt: formatProjectTimePointer(project.LastOpenedAt), + Metadata: metadataPointer(project.Metadata), + }, nil +} + +func (u *projectUseCase) Update(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.ProjectUpdateReq) (*models.ProjectUpdateResp, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + + hasName := strings.TrimSpace(req.Name) != "" + hasMetadata := req.Metadata != nil && strings.TrimSpace(req.Metadata.LastViewMode) != "" + if !hasName && !hasMetadata { + return nil, response.InvalidArgumentError("request", "name or metadata.last_view_mode required") + } + if req.Metadata != nil { + lastViewMode := strings.TrimSpace(req.Metadata.LastViewMode) + if lastViewMode != "" && lastViewMode != "preview" && lastViewMode != "code" { + return nil, response.InvalidArgumentError("metadata.last_view_mode", "unsupported") + } + } + + existing, err := u.repo.GetProjectByID(ctx, principal.UserID, strings.TrimSpace(projectID)) + if err != nil { + return nil, u.apiError(err) + } + + name := existing.Name + if strings.TrimSpace(req.Name) != "" { + name = strings.TrimSpace(req.Name) + } + + metadata := existing.Metadata + if req.Metadata != nil && strings.TrimSpace(req.Metadata.LastViewMode) != "" { + metadata.LastViewMode = strings.TrimSpace(req.Metadata.LastViewMode) + } + + project, err := u.repo.UpdateProject(ctx, &models.UpdateProjectInput{ + ProjectID: strings.TrimSpace(projectID), + OwnerID: principal.UserID, + Name: name, + Metadata: metadata, + Now: u.now().UTC(), + }) + if err != nil { + return nil, u.apiError(err) + } + + return &models.ProjectUpdateResp{ + ID: project.ID, + Name: project.Name, + UpdatedAt: formatProjectTime(project.UpdatedAt), + Metadata: metadataPointer(project.Metadata), + }, nil +} + +func (u *projectUseCase) Delete(ctx context.Context, principal models.AuthPrincipal, projectID string) (*models.ProjectDeleteResp, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + if err := u.repo.SoftDeleteProject(ctx, principal.UserID, strings.TrimSpace(projectID), u.now().UTC()); err != nil { + return nil, u.apiError(err) + } + return &models.ProjectDeleteResp{Success: true}, nil +} + +func (u *projectUseCase) Usage(ctx context.Context, principal models.AuthPrincipal) (*models.ProjectUsageResp, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + used, err := u.repo.CountActiveProjectsByOwner(ctx, principal.UserID) + if err != nil { + return nil, u.apiError(err) + } + plan, err := u.repo.GetUserPlan(ctx, principal.UserID) + if err != nil { + return nil, u.apiError(err) + } + limit, ok := planProjectLimits[strings.ToLower(strings.TrimSpace(plan))] + if !ok { + limit = planProjectLimits["free"] + } + return &models.ProjectUsageResp{Used: used, Limit: limit}, nil +} + +func (u *projectUseCase) ListMessages(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.ChatMessagesReq) (*models.ChatMessagesResp, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + trimmedProjectID := strings.TrimSpace(projectID) + if _, err := u.repo.GetProjectByID(ctx, principal.UserID, trimmedProjectID); err != nil { + return nil, u.apiError(err) + } + messages, nextCursor, err := u.repo.ListProjectChatMessages(ctx, principal.UserID, trimmedProjectID, strings.TrimSpace(req.Cursor), 200) + if err != nil { + return nil, u.apiError(err) + } + items := make([]models.ChatMessageItem, 0, len(messages)) + for _, item := range messages { + items = append(items, models.ChatMessageItem{ + ID: item.ID, + Role: item.Role, + Content: item.Content, + CreatedAt: formatProjectTime(item.CreatedAt), + }) + } + return &models.ChatMessagesResp{Items: items, NextCursor: nextCursor}, nil +} + +func (u *projectUseCase) CreateMessage(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.ChatMessageCreateReq, onDelta func(string) error) (*models.ChatMessageStreamDoneResp, error) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + content := strings.TrimSpace(req.Content) + if content == "" { + return nil, response.InvalidArgumentError("content", "required") + } + trimmedProjectID := strings.TrimSpace(projectID) + project, err := u.repo.GetProjectByID(ctx, principal.UserID, trimmedProjectID) + if err != nil { + return nil, u.apiError(err) + } + chatSession, err := u.ensureProjectChatSession(ctx, principal.UserID, project) + if err != nil { + return nil, gatewayAPIError(err) + } + _, err = u.repo.CreateProjectChatMessage(ctx, &models.CreateProjectChatMessageInput{ + ID: token.NewID("m"), + ProjectID: trimmedProjectID, + OwnerID: principal.UserID, + Role: "user", + Content: content, + Now: u.now().UTC(), + }) + if err != nil { + return nil, err + } + + assistantText := strings.Builder{} + if err = u.gateway.StreamChat(ctx, chatSession.GatewaySessionID, &models.AgentChatStreamReq{ + Message: content, + Deep: req.Deep, + SessionID: chatSession.AgentChatSessionID, + WorkspacePath: chatSession.WorkspacePath, + ProjectName: project.Name, + }, func(event *models.AgentSSEEvent) error { + switch event.Event { + case "assistant_delta": + var payload struct { + Content string `json:"content"` + } + if json.Unmarshal(event.Data, &payload) == nil { + delta := payload.Content + if strings.TrimSpace(delta) != "" { + assistantText.WriteString(delta) + if onDelta != nil { + return onDelta(delta) + } + } + } + case "session": + var payload struct { + SessionID string `json:"session_id"` + WorkspacePath string `json:"workspace_path"` + } + if json.Unmarshal(event.Data, &payload) == nil { + updatedSession, upsertErr := u.repo.UpsertProjectChatSession(ctx, &models.UpsertProjectChatSessionInput{ + ProjectID: trimmedProjectID, + OwnerID: principal.UserID, + GatewaySessionID: chatSession.GatewaySessionID, + AgentChatSessionID: firstNonEmpty(strings.TrimSpace(payload.SessionID), chatSession.AgentChatSessionID), + WorkspacePath: firstNonEmpty(strings.TrimSpace(payload.WorkspacePath), chatSession.WorkspacePath), + Now: u.now().UTC(), + }) + if upsertErr != nil { + return upsertErr + } + chatSession = updatedSession + } + case "error": + var payload struct { + Message string `json:"message"` + } + if json.Unmarshal(event.Data, &payload) == nil && strings.TrimSpace(payload.Message) != "" { + return errors.New(payload.Message) + } + return errors.New("agent chat stream failed") + } + return nil + }); err != nil { + return nil, err + } + assistantMessage, err := u.repo.CreateProjectChatMessage(ctx, &models.CreateProjectChatMessageInput{ + ID: token.NewID("m"), + ProjectID: trimmedProjectID, + OwnerID: principal.UserID, + Role: "assistant", + Content: strings.TrimSpace(assistantText.String()), + Now: u.now().UTC(), + }) + if err != nil { + return nil, err + } + return &models.ChatMessageStreamDoneResp{MessageID: assistantMessage.ID, Changes: []models.FileChange{}}, nil +} + +func (u *projectUseCase) ensureProjectChatSession(ctx context.Context, ownerID string, project *models.Project) (*models.ProjectChatSession, error) { + chatSession, err := u.repo.GetProjectChatSession(ctx, ownerID, project.ID) + if err != nil { + return nil, err + } + if chatSession != nil && strings.TrimSpace(chatSession.GatewaySessionID) != "" { + return chatSession, nil + } + + mutex := u.projectSessionMutex(ownerID, project.ID) + mutex.Lock() + defer mutex.Unlock() + + chatSession, err = u.repo.GetProjectChatSession(ctx, ownerID, project.ID) + if err != nil { + return nil, err + } + if chatSession != nil && strings.TrimSpace(chatSession.GatewaySessionID) != "" { + return chatSession, nil + } + + job, err := u.latestProjectRuntime(ctx, ownerID, project.ID) + if err != nil { + return nil, err + } + seedGatewaySessionID := strings.TrimSpace(job.GatewaySessionID) + seedAgentChatSessionID := firstNonEmpty(strings.TrimSpace(job.AgentSessionID), token.NewID("chat")) + seedWorkspacePath := firstNonEmpty(strings.TrimSpace(job.WorkspacePath), defaultGenerationWorkspace) + return u.repo.UpsertProjectChatSession(ctx, &models.UpsertProjectChatSessionInput{ + ProjectID: project.ID, + OwnerID: ownerID, + GatewaySessionID: seedGatewaySessionID, + AgentChatSessionID: seedAgentChatSessionID, + WorkspacePath: seedWorkspacePath, + Now: u.now().UTC(), + }) +} + +func (u *projectUseCase) latestProjectRuntime(ctx context.Context, ownerID, projectID string) (*models.Job, error) { + if u.jobRepo == nil { + return nil, autherr.ErrProjectRuntimeUnavailable + } + job, err := u.jobRepo.GetLatestProjectRuntime(ctx, ownerID, projectID) + if err != nil { + if errors.Is(err, autherr.ErrJobNotFound) { + return nil, autherr.ErrProjectRuntimeUnavailable + } + return nil, err + } + if job == nil || strings.TrimSpace(job.GatewaySessionID) == "" { + return nil, autherr.ErrProjectRuntimeUnavailable + } + return job, nil +} + +func firstNonEmpty(values ...string) string { + for _, item := range values { + if strings.TrimSpace(item) != "" { + return item + } + } + return "" +} + +func (u *projectUseCase) CreateGeneration(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.GenerationCreateReq) (*models.GenerationCreateResp, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, response.UnauthorizedError() + } + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + return nil, response.InvalidArgumentError("prompt", "required") + } + trimmedProjectID := strings.TrimSpace(projectID) + project, err := u.repo.GetProjectByID(ctx, principal.UserID, trimmedProjectID) + if err != nil { + return nil, u.apiError(err) + } + if u.jobRepo == nil || u.gateway == nil { + return nil, response.InternalError() + } + + now := u.now().UTC() + assistantPlaceholderAt := now.Add(time.Millisecond) + job, err := u.jobRepo.CreateJob(ctx, &models.CreateJobInput{ + ID: token.NewID("job_gen"), + OwnerID: principal.UserID, + ProjectID: trimmedProjectID, + Type: generationJobType, + Status: generationJobQueued, + Progress: 0, + Logs: []string{ + "Generation queued", + }, + RequestPayload: models.GenerationRequestPayload{ + Prompt: prompt, + Attachments: req.Attachments, + Deep: req.Deep, + }, + Now: now, + }) + if err != nil { + zap.L().Error("create generation job failed", zap.Error(err), zap.String("project_id", trimmedProjectID), zap.String("user_id", principal.UserID)) + return nil, response.InternalError() + } + + userMessageID := token.NewID("m") + assistantMessageID := token.NewID("m") + if _, err = u.repo.CreateProjectChatMessage(ctx, &models.CreateProjectChatMessageInput{ + ID: userMessageID, + ProjectID: trimmedProjectID, + OwnerID: principal.UserID, + Role: "user", + Content: prompt, + Now: now, + }); err != nil { + zap.L().Error("create generation user chat message failed", zap.Error(err), zap.String("project_id", trimmedProjectID), zap.String("user_id", principal.UserID)) + return nil, response.InternalError() + } + if _, err = u.repo.CreateProjectChatMessage(ctx, &models.CreateProjectChatMessageInput{ + ID: assistantMessageID, + ProjectID: trimmedProjectID, + OwnerID: principal.UserID, + Role: "assistant", + Content: "", + Now: assistantPlaceholderAt, + }); err != nil { + zap.L().Error("create generation assistant chat placeholder failed", zap.Error(err), zap.String("project_id", trimmedProjectID), zap.String("user_id", principal.UserID)) + return nil, response.InternalError() + } + + state := &generationRunState{ + job: models.Job{ + ID: job.ID, + OwnerID: principal.UserID, + ProjectID: trimmedProjectID, + Type: generationJobType, + Status: generationJobQueued, + Progress: 0, + Logs: append([]string{}, job.Logs...), + RequestPayload: job.RequestPayload, + WorkspacePath: defaultGenerationWorkspace, + AgentSessionID: token.NewID("chat"), + CreatedAt: now, + UpdatedAt: now, + }, + ownerID: principal.UserID, + projectID: trimmedProjectID, + projectName: project.Name, + prompt: prompt, + userMessageID: userMessageID, + assistantMessageID: assistantMessageID, + } + + u.runAsync(func() { + u.runGeneration(state, prompt, req.Attachments, req.Deep) + }) + + return &models.GenerationCreateResp{ + JobID: job.ID, + Status: generationJobQueued, + }, nil +} + +func (u *projectUseCase) runGeneration(state *generationRunState, prompt string, attachments []models.AttachmentRef, deep bool) { + ctx, cancel := context.WithTimeout(context.Background(), generationExecutionTimeout) + defer cancel() + + startedAt := u.now().UTC() + state.job.Status = generationJobStarting + state.job.Progress = 5 + state.job.StartedAt = &startedAt + state.job.UpdatedAt = startedAt + state.job.Logs = appendJobLog(state.job.Logs, "Preparing sandbox") + u.persistJob(ctx, &state.job) + u.persistProjectStatus(ctx, state.ownerID, state.projectID, projectStatusBuilding) + + sessionInfo, err := u.gateway.EnsureSessionReady(ctx) + if err != nil { + u.failGeneration(ctx, state, fmt.Errorf("prepare sandbox: %w", err)) + return + } + + state.job.GatewaySessionID = sessionInfo.GatewaySessionID + state.job.Status = generationJobRunning + state.job.Progress = maxInt(state.job.Progress, 15) + state.job.Logs = appendJobLog(state.job.Logs, "Sandbox ready") + state.job.UpdatedAt = u.now().UTC() + u.persistJob(ctx, &state.job) + if _, upsertErr := u.repo.UpsertProjectChatSession(ctx, &models.UpsertProjectChatSessionInput{ + ProjectID: state.projectID, + OwnerID: state.ownerID, + GatewaySessionID: strings.TrimSpace(state.job.GatewaySessionID), + AgentChatSessionID: firstNonEmpty(strings.TrimSpace(state.job.AgentSessionID), token.NewID("chat")), + WorkspacePath: firstNonEmpty(strings.TrimSpace(state.job.WorkspacePath), defaultGenerationWorkspace), + Now: u.now().UTC(), + }); upsertErr != nil { + zap.L().Warn("seed project chat session from generation runtime failed", zap.Error(upsertErr), zap.String("project_id", state.projectID)) + } + + streamReq := &models.AgentChatStreamReq{ + Message: buildGenerationMessage(prompt, attachments), + Deep: deep, + SessionID: state.job.AgentSessionID, + WorkspacePath: state.job.WorkspacePath, + ProjectName: state.projectName, + Iterations: defaultGenerationIterations, + } + + err = u.gateway.StreamChat(ctx, state.job.GatewaySessionID, streamReq, func(event *models.AgentSSEEvent) error { + return u.handleGenerationEvent(ctx, state, event) + }) + if err != nil { + u.failGeneration(ctx, state, err) + return + } + if !state.done { + u.failGeneration(ctx, state, errors.New("agent stream ended before done event")) + return + } +} + +func (u *projectUseCase) handleGenerationEvent(ctx context.Context, state *generationRunState, event *models.AgentSSEEvent) error { + switch event.Event { + case "ping": + return nil + case "assistant_delta": + var payload struct { + Content string `json:"content"` + } + if err := json.Unmarshal(event.Data, &payload); err == nil && payload.Content != "" { + state.assistantText.WriteString(payload.Content) + state.job.Progress = maxInt(state.job.Progress, 55) + state.job.Result = buildGenerationResult(state, nil) + state.job.UpdatedAt = u.now().UTC() + if persistErr := u.persistGenerationAssistantMessage(ctx, state); persistErr != nil { + return persistErr + } + u.persistJob(ctx, &state.job) + } + return nil + case "route": + var payload struct { + Intent string `json:"intent"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(event.Data, &payload); err == nil { + state.routeIntent = strings.TrimSpace(payload.Intent) + message := "Agent route resolved" + if payload.Intent != "" { + message = fmt.Sprintf("Routed to %s mode", payload.Intent) + } + state.job.Progress = maxInt(state.job.Progress, 20) + state.job.Logs = appendJobLog(state.job.Logs, message) + state.job.Result = buildGenerationResult(state, nil) + state.job.UpdatedAt = u.now().UTC() + u.persistJob(ctx, &state.job) + } + return nil + case "session": + var payload struct { + SessionID string `json:"session_id"` + WorkspacePath string `json:"workspace_path"` + Mode string `json:"mode"` + } + if err := json.Unmarshal(event.Data, &payload); err == nil { + if strings.TrimSpace(payload.SessionID) != "" { + state.job.AgentSessionID = strings.TrimSpace(payload.SessionID) + } + if strings.TrimSpace(payload.WorkspacePath) != "" { + state.job.WorkspacePath = strings.TrimSpace(payload.WorkspacePath) + } + if _, upsertErr := u.repo.UpsertProjectChatSession(ctx, &models.UpsertProjectChatSessionInput{ + ProjectID: state.projectID, + OwnerID: state.ownerID, + GatewaySessionID: strings.TrimSpace(state.job.GatewaySessionID), + AgentChatSessionID: firstNonEmpty(strings.TrimSpace(state.job.AgentSessionID), token.NewID("chat")), + WorkspacePath: firstNonEmpty(strings.TrimSpace(state.job.WorkspacePath), defaultGenerationWorkspace), + Now: u.now().UTC(), + }); upsertErr != nil { + zap.L().Warn("update project chat session from generation event failed", zap.Error(upsertErr), zap.String("project_id", state.projectID)) + } + state.job.Progress = maxInt(state.job.Progress, 25) + state.job.Logs = appendJobLog(state.job.Logs, "Agent session established") + state.job.Result = buildGenerationResult(state, nil) + state.job.UpdatedAt = u.now().UTC() + u.persistJob(ctx, &state.job) + } + return nil + case "planner_fallback": + state.job.Progress = maxInt(state.job.Progress, 30) + state.job.Logs = appendJobLog(state.job.Logs, "Planner fallback engaged") + state.job.UpdatedAt = u.now().UTC() + u.persistJob(ctx, &state.job) + return nil + case "plan_ready": + state.job.Progress = maxInt(state.job.Progress, 40) + state.job.Logs = appendJobLog(state.job.Logs, "Execution plan ready") + state.job.UpdatedAt = u.now().UTC() + u.persistJob(ctx, &state.job) + return nil + case "iteration_start": + var payload struct { + Iteration int `json:"iteration"` + MaxIterations int `json:"max_iterations"` + } + if err := json.Unmarshal(event.Data, &payload); err == nil { + progress := 45 + if payload.MaxIterations > 0 && payload.Iteration > 0 { + progress = 45 + ((payload.Iteration - 1) * 40 / payload.MaxIterations) + } + state.job.Progress = maxInt(state.job.Progress, progress) + state.job.Logs = appendJobLog(state.job.Logs, fmt.Sprintf("Iteration %d started", maxInt(payload.Iteration, 1))) + state.job.UpdatedAt = u.now().UTC() + u.persistJob(ctx, &state.job) + } + return nil + case "iteration_complete": + var payload struct { + Iteration int `json:"iteration"` + Complete bool `json:"complete"` + } + if err := json.Unmarshal(event.Data, &payload); err == nil { + progress := 80 + if payload.Complete { + progress = 90 + } + state.job.Progress = maxInt(state.job.Progress, progress) + state.job.Logs = appendJobLog(state.job.Logs, fmt.Sprintf("Iteration %d complete", maxInt(payload.Iteration, 1))) + state.job.UpdatedAt = u.now().UTC() + u.persistJob(ctx, &state.job) + } + return nil + case "tool_call": + var payload struct { + Name string `json:"name"` + } + if err := json.Unmarshal(event.Data, &payload); err == nil && strings.TrimSpace(payload.Name) != "" { + state.job.Logs = appendJobLog(state.job.Logs, fmt.Sprintf("Tool call: %s", payload.Name)) + state.job.UpdatedAt = u.now().UTC() + u.persistJob(ctx, &state.job) + } + return nil + case "error": + var payload struct { + Message string `json:"message"` + } + if err := json.Unmarshal(event.Data, &payload); err == nil && strings.TrimSpace(payload.Message) != "" { + return errors.New(payload.Message) + } + return errors.New("agent stream returned error event") + case "done": + var payload map[string]any + _ = json.Unmarshal(event.Data, &payload) + completedAt := u.now().UTC() + if err := u.persistGenerationChatHistory(ctx, state, completedAt); err != nil { + return err + } + state.job.Status = generationJobSuccess + state.job.Progress = 100 + state.job.CompletedAt = &completedAt + state.job.UpdatedAt = completedAt + state.job.Result = buildGenerationResult(state, payload) + state.job.Logs = appendJobLog(state.job.Logs, "Generation complete") + state.done = true + u.persistJob(ctx, &state.job) + u.persistProjectStatus(ctx, state.ownerID, state.projectID, projectStatusDraft) + return nil + default: + return nil + } +} + +func (u *projectUseCase) failGeneration(ctx context.Context, state *generationRunState, err error) { + completedAt := u.now().UTC() + state.job.Status = generationJobFailed + state.job.Progress = maxInt(state.job.Progress, 100) + state.job.CompletedAt = &completedAt + state.job.UpdatedAt = completedAt + state.job.ErrorMessage = strings.TrimSpace(err.Error()) + state.job.Result = buildGenerationFailureResult(state) + state.job.Logs = appendJobLog(state.job.Logs, fmt.Sprintf("Generation failed: %s", state.job.ErrorMessage)) + z := zap.L().With(zap.String("job_id", state.job.ID), zap.String("project_id", state.projectID)) + z.Error("generation job failed", zap.Error(err)) + u.persistJob(ctx, &state.job) + u.persistProjectStatus(ctx, state.ownerID, state.projectID, projectStatusDraft) +} + +func (u *projectUseCase) persistJob(ctx context.Context, job *models.Job) { + if u.jobRepo == nil { + return + } + if err := u.jobRepo.UpdateJob(ctx, &models.UpdateJobInput{ + JobID: job.ID, + Status: job.Status, + Progress: job.Progress, + Logs: append([]string{}, job.Logs...), + Result: job.Result, + GatewaySessionID: job.GatewaySessionID, + AgentSessionID: job.AgentSessionID, + WorkspacePath: job.WorkspacePath, + ErrorMessage: job.ErrorMessage, + StartedAt: job.StartedAt, + CompletedAt: job.CompletedAt, + UpdatedAt: job.UpdatedAt, + }); err != nil { + zap.L().Error("persist generation job state failed", zap.Error(err), zap.String("job_id", job.ID)) + } +} + +func (u *projectUseCase) persistProjectStatus(ctx context.Context, ownerID, projectID, status string) { + if u.repo == nil { + return + } + if err := u.repo.UpdateProjectStatus(ctx, ownerID, projectID, status, u.now().UTC()); err != nil { + zap.L().Warn("persist project status failed", zap.Error(err), zap.String("project_id", projectID), zap.String("status", status)) + } +} + +func (u *projectUseCase) persistGenerationAssistantMessage(ctx context.Context, state *generationRunState) error { + if u.repo == nil || strings.TrimSpace(state.assistantMessageID) == "" { + return nil + } + return u.repo.UpdateProjectChatMessageContent(ctx, state.ownerID, state.projectID, state.assistantMessageID, strings.TrimSpace(state.assistantText.String())) +} + +func (u *projectUseCase) persistGenerationChatHistory(ctx context.Context, state *generationRunState, completedAt time.Time) error { + if u.repo == nil { + return nil + } + + agentSessionID := strings.TrimSpace(state.job.AgentSessionID) + if agentSessionID == "" { + agentSessionID = token.NewID("chat") + } + workspacePath := strings.TrimSpace(state.job.WorkspacePath) + if workspacePath == "" { + workspacePath = defaultGenerationWorkspace + } + + if _, err := u.repo.UpsertProjectChatSession(ctx, &models.UpsertProjectChatSessionInput{ + ProjectID: state.projectID, + OwnerID: state.ownerID, + GatewaySessionID: strings.TrimSpace(state.job.GatewaySessionID), + AgentChatSessionID: agentSessionID, + WorkspacePath: workspacePath, + Now: completedAt, + }); err != nil { + return err + } + + if err := u.persistGenerationAssistantMessage(ctx, state); err != nil { + return err + } + + return nil +} + +func normalizePagination(page, pageSize int) (int, int) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = defaultProjectPageSize + } + if pageSize > maxProjectPageSize { + pageSize = maxProjectPageSize + } + return page, pageSize +} + +func formatProjectTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + +func formatProjectTimePointer(value *time.Time) string { + if value == nil { + return "" + } + return formatProjectTime(*value) +} + +func metadataPointer(metadata models.ProjectMetadata) *models.ProjectMetadata { + if strings.TrimSpace(metadata.LastViewMode) == "" { + return nil + } + copy := metadata + return © +} + +func buildGenerationResult(state *generationRunState, terminalPayload map[string]any) map[string]any { + result := map[string]any{ + "project_id": state.projectID, + "gateway_session_id": state.job.GatewaySessionID, + "agent_session_id": state.job.AgentSessionID, + "workspace_path": state.job.WorkspacePath, + "route_intent": state.routeIntent, + "assistant_text": strings.TrimSpace(state.assistantText.String()), + } + if terminalPayload != nil { + result["terminal_event_data"] = terminalPayload + } + return result +} + +func buildGenerationFailureResult(state *generationRunState) map[string]any { + result := buildGenerationResult(state, nil) + result["error"] = state.job.ErrorMessage + return result +} + +func appendJobLog(logs []string, message string) []string { + trimmed := strings.TrimSpace(message) + if trimmed == "" { + return logs + } + updated := append(append([]string{}, logs...), trimmed) + if len(updated) <= maxPersistedGenerationJobLogs { + return updated + } + return updated[len(updated)-maxPersistedGenerationJobLogs:] +} + +func buildGenerationMessage(prompt string, attachments []models.AttachmentRef) string { + prompt = strings.TrimSpace(prompt) + if len(attachments) == 0 { + return prompt + } + var builder strings.Builder + builder.WriteString(prompt) + builder.WriteString("\n\nReferenced attachments:\n") + for _, attachment := range attachments { + name := strings.TrimSpace(attachment.Name) + if name == "" { + name = attachment.FileID + } + builder.WriteString("- ") + builder.WriteString(name) + if strings.TrimSpace(attachment.FileID) != "" { + builder.WriteString(" (") + builder.WriteString(strings.TrimSpace(attachment.FileID)) + builder.WriteString(")") + } + builder.WriteString("\n") + } + builder.WriteString("If attachment contents are unavailable in the workspace, proceed using the prompt and state any assumptions in generated output.") + return builder.String() +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func (u *projectUseCase) apiError(err error) *response.APIError { + switch { + case err == nil: + return nil + case errors.Is(err, autherr.ErrProjectNotFound): + return response.NotFoundError() + case errors.Is(err, autherr.ErrProjectRuntimeUnavailable): + return response.RuntimeUnavailableError() + case errors.Is(err, autherr.ErrUserNotFound): + return response.UnauthorizedError() + default: + return response.InternalError() + } +} diff --git a/app/be/internal/biz/project_test.go b/app/be/internal/biz/project_test.go new file mode 100644 index 0000000..7428c73 --- /dev/null +++ b/app/be/internal/biz/project_test.go @@ -0,0 +1,722 @@ +package biz + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/autherr" + "github.com/stretchr/testify/require" +) + +type fakeProjectRepo struct { + projects map[string]*models.Project + plan string + statusTransitions []string + chatSessions map[string]*models.ProjectChatSession + chatMessages map[string][]*models.ProjectChatMessage +} + +func (f *fakeProjectRepo) CreateProject(_ context.Context, input *models.CreateProjectInput) (*models.Project, error) { + project := &models.Project{ + ID: input.ID, + OwnerID: input.OwnerID, + Name: input.Name, + Template: input.Template, + Status: input.Status, + CreatedAt: input.Now, + UpdatedAt: input.Now, + } + if f.projects == nil { + f.projects = map[string]*models.Project{} + } + f.projects[project.ID] = project + return project, nil +} + +func (f *fakeProjectRepo) ListProjects(_ context.Context, filter *models.ProjectListFilter) ([]*models.Project, int, error) { + if filter.View == "shared" { + return []*models.Project{}, 0, nil + } + items := make([]*models.Project, 0) + for _, item := range f.projects { + if item.OwnerID == filter.OwnerID && item.DeletedAt == nil { + items = append(items, item) + } + } + return items, len(items), nil +} + +func (f *fakeProjectRepo) GetProjectByID(_ context.Context, ownerID, projectID string) (*models.Project, error) { + project, ok := f.projects[projectID] + if !ok || project.OwnerID != ownerID || project.DeletedAt != nil { + return nil, autherr.ErrProjectNotFound + } + return project, nil +} + +func (f *fakeProjectRepo) GetProjectAndTouch(_ context.Context, ownerID, projectID string, now time.Time) (*models.Project, error) { + project, err := f.GetProjectByID(context.Background(), ownerID, projectID) + if err != nil { + return nil, err + } + project.LastOpenedAt = &now + return project, nil +} + +func (f *fakeProjectRepo) UpdateProject(_ context.Context, input *models.UpdateProjectInput) (*models.Project, error) { + project, ok := f.projects[input.ProjectID] + if !ok || project.OwnerID != input.OwnerID || project.DeletedAt != nil { + return nil, autherr.ErrProjectNotFound + } + project.Name = input.Name + project.Metadata = input.Metadata + project.UpdatedAt = input.Now + return project, nil +} + +func (f *fakeProjectRepo) UpdateProjectStatus(_ context.Context, ownerID, projectID, status string, now time.Time) error { + project, ok := f.projects[projectID] + if !ok || project.OwnerID != ownerID || project.DeletedAt != nil { + return autherr.ErrProjectNotFound + } + project.Status = status + project.UpdatedAt = now + f.statusTransitions = append(f.statusTransitions, status) + return nil +} + +func (f *fakeProjectRepo) SoftDeleteProject(_ context.Context, ownerID, projectID string, now time.Time) error { + project, ok := f.projects[projectID] + if !ok || project.OwnerID != ownerID || project.DeletedAt != nil { + return autherr.ErrProjectNotFound + } + project.DeletedAt = &now + project.UpdatedAt = now + return nil +} + +func (f *fakeProjectRepo) CountActiveProjectsByOwner(_ context.Context, ownerID string) (int, error) { + count := 0 + for _, item := range f.projects { + if item.OwnerID == ownerID && item.DeletedAt == nil { + count++ + } + } + return count, nil +} + +func (f *fakeProjectRepo) GetUserPlan(_ context.Context, _ string) (string, error) { + if f.plan == "" { + return "free", nil + } + return f.plan, nil +} + +func (f *fakeProjectRepo) GetProjectChatSession(_ context.Context, ownerID, projectID string) (*models.ProjectChatSession, error) { + if f.chatSessions == nil { + return nil, nil + } + session, ok := f.chatSessions[projectID] + if !ok || session.OwnerID != ownerID { + return nil, nil + } + return session, nil +} + +func (f *fakeProjectRepo) UpsertProjectChatSession(_ context.Context, input *models.UpsertProjectChatSessionInput) (*models.ProjectChatSession, error) { + if f.chatSessions == nil { + f.chatSessions = map[string]*models.ProjectChatSession{} + } + session := &models.ProjectChatSession{ + ProjectID: input.ProjectID, + OwnerID: input.OwnerID, + GatewaySessionID: input.GatewaySessionID, + AgentChatSessionID: input.AgentChatSessionID, + WorkspacePath: input.WorkspacePath, + CreatedAt: input.Now, + UpdatedAt: input.Now, + LastMessageAt: input.Now, + } + if existing, ok := f.chatSessions[input.ProjectID]; ok { + session.CreatedAt = existing.CreatedAt + } + f.chatSessions[input.ProjectID] = session + return session, nil +} + +func (f *fakeProjectRepo) ListProjectChatMessages(_ context.Context, ownerID, projectID, _ string, _ int) ([]*models.ProjectChatMessage, *string, error) { + messages := make([]*models.ProjectChatMessage, 0) + for _, item := range f.chatMessages[projectID] { + if item.OwnerID == ownerID { + messages = append(messages, item) + } + } + return messages, nil, nil +} + +func (f *fakeProjectRepo) UpdateProjectChatMessageContent(_ context.Context, ownerID, projectID, messageID, content string) error { + for _, item := range f.chatMessages[projectID] { + if item.ID == messageID && item.OwnerID == ownerID { + item.Content = content + return nil + } + } + return autherr.ErrProjectNotFound +} + +func (f *fakeProjectRepo) CreateProjectChatMessage(_ context.Context, input *models.CreateProjectChatMessageInput) (*models.ProjectChatMessage, error) { + if f.chatMessages == nil { + f.chatMessages = map[string][]*models.ProjectChatMessage{} + } + message := &models.ProjectChatMessage{ + ID: input.ID, + ProjectID: input.ProjectID, + OwnerID: input.OwnerID, + Role: input.Role, + Content: input.Content, + CreatedAt: input.Now, + } + f.chatMessages[input.ProjectID] = append(f.chatMessages[input.ProjectID], message) + if session, ok := f.chatSessions[input.ProjectID]; ok { + session.UpdatedAt = input.Now + session.LastMessageAt = input.Now + } + return message, nil +} + +type fakeJobRepo struct { + jobs map[string]*models.Job +} + +func (f *fakeJobRepo) CreateJob(_ context.Context, input *models.CreateJobInput) (*models.Job, error) { + if f.jobs == nil { + f.jobs = map[string]*models.Job{} + } + job := &models.Job{ + ID: input.ID, + OwnerID: input.OwnerID, + ProjectID: input.ProjectID, + Type: input.Type, + Status: input.Status, + Progress: input.Progress, + Logs: append([]string{}, input.Logs...), + Result: input.Result, + RequestPayload: input.RequestPayload, + GatewaySessionID: input.GatewaySessionID, + AgentSessionID: input.AgentSessionID, + WorkspacePath: input.WorkspacePath, + ErrorMessage: input.ErrorMessage, + CreatedAt: input.Now, + UpdatedAt: input.Now, + } + f.jobs[job.ID] = job + return job, nil +} + +func (f *fakeJobRepo) GetJobByID(_ context.Context, ownerID, jobID string) (*models.Job, error) { + job, ok := f.jobs[jobID] + if !ok || job.OwnerID != ownerID { + return nil, autherr.ErrJobNotFound + } + return job, nil +} + +func (f *fakeJobRepo) GetLatestProjectRuntime(_ context.Context, ownerID, projectID string) (*models.Job, error) { + priority := map[string]int{ + "RUNNING": 1, + "STARTING": 2, + "SUCCESS": 3, + "FAILED": 4, + } + var latest *models.Job + for _, job := range f.jobs { + if job.OwnerID != ownerID || job.ProjectID != projectID || job.Type != "APP_GENERATION" || strings.TrimSpace(job.GatewaySessionID) == "" { + continue + } + if latest == nil { + latest = job + continue + } + left := priority[job.Status] + if left == 0 { + left = 99 + } + right := priority[latest.Status] + if right == 0 { + right = 99 + } + if left < right || (left == right && job.UpdatedAt.After(latest.UpdatedAt)) { + latest = job + } + } + if latest == nil { + return nil, autherr.ErrJobNotFound + } + return latest, nil +} + +func (f *fakeJobRepo) UpdateJob(_ context.Context, input *models.UpdateJobInput) error { + job, ok := f.jobs[input.JobID] + if !ok { + return autherr.ErrJobNotFound + } + job.Status = input.Status + job.Progress = input.Progress + job.Logs = append([]string{}, input.Logs...) + job.Result = input.Result + job.GatewaySessionID = input.GatewaySessionID + job.AgentSessionID = input.AgentSessionID + job.WorkspacePath = input.WorkspacePath + job.ErrorMessage = input.ErrorMessage + job.StartedAt = input.StartedAt + job.CompletedAt = input.CompletedAt + job.UpdatedAt = input.UpdatedAt + return nil +} + +type fakeAgentlandGateway struct { + sessionID string + ensureCalls int + ensureErr error + streamErr error + events []*models.AgentSSEEvent + lastStreamReq *models.AgentChatStreamReq + previewInfo *models.GatewayPreviewInfo + fsTree *models.GatewayFSTreeResp + fsFiles map[string]*models.GatewayFSFileResp + contextInfo *models.GatewayExecContextInfo + executionResult *models.GatewayExecutionResult + probeStatuses []int + createPreviewErr error + fsErr error + contextErr error + executeErr error + probeErr error + lastExecuteCode string +} + +func (f *fakeAgentlandGateway) EnsureSessionReady(_ context.Context) (*models.AgentSessionInfo, error) { + f.ensureCalls++ + if f.ensureErr != nil { + return nil, f.ensureErr + } + if f.sessionID == "" { + f.sessionID = "sess_gateway_1" + } + return &models.AgentSessionInfo{GatewaySessionID: f.sessionID}, nil +} + +func (f *fakeAgentlandGateway) StreamChat(_ context.Context, _ string, req *models.AgentChatStreamReq, onEvent func(*models.AgentSSEEvent) error) error { + if req != nil { + copyReq := *req + f.lastStreamReq = ©Req + } + if f.streamErr != nil { + return f.streamErr + } + for _, event := range f.events { + if err := onEvent(event); err != nil { + return err + } + } + return nil +} + +func (f *fakeAgentlandGateway) CreatePreview(_ context.Context, _ string, port int) (*models.GatewayPreviewInfo, error) { + if f.createPreviewErr != nil { + return nil, f.createPreviewErr + } + if f.previewInfo != nil { + return f.previewInfo, nil + } + return &models.GatewayPreviewInfo{PreviewToken: "pv_123", PreviewURL: "/p/pv_123/", Port: port}, nil +} + +func (f *fakeAgentlandGateway) GetFSTree(_ context.Context, _ string, targetPath string, _ int) (*models.GatewayFSTreeResp, error) { + if f.fsErr != nil { + return nil, f.fsErr + } + if f.fsTree != nil { + return f.fsTree, nil + } + return &models.GatewayFSTreeResp{Root: targetPath}, nil +} + +func (f *fakeAgentlandGateway) GetFSFile(_ context.Context, _ string, targetPath, encoding string) (*models.GatewayFSFileResp, error) { + if f.fsErr != nil { + return nil, f.fsErr + } + if f.fsFiles != nil { + if item, ok := f.fsFiles[targetPath]; ok { + return item, nil + } + } + return &models.GatewayFSFileResp{Path: targetPath, Encoding: encoding, Content: ""}, nil +} + +func (f *fakeAgentlandGateway) CreateExecContext(_ context.Context, _ string, language, cwd string) (*models.GatewayExecContextInfo, error) { + if f.contextErr != nil { + return nil, f.contextErr + } + if f.contextInfo != nil { + return f.contextInfo, nil + } + return &models.GatewayExecContextInfo{ContextID: "ctx_123", Language: language, CWD: cwd, State: "ready"}, nil +} + +func (f *fakeAgentlandGateway) ExecuteInContext(_ context.Context, _ string, contextID, code string, _ int) (*models.GatewayExecutionResult, error) { + f.lastExecuteCode = code + if f.executeErr != nil { + return nil, f.executeErr + } + if f.executionResult != nil { + return f.executionResult, nil + } + return &models.GatewayExecutionResult{ContextID: contextID, ExecutionID: "exec_123", ExitCode: 0}, nil +} + +func (f *fakeAgentlandGateway) ProbePort(_ context.Context, _ string, _ int, _ string) (int, error) { + if f.probeErr != nil { + return 0, f.probeErr + } + if len(f.probeStatuses) == 0 { + return 200, nil + } + status := f.probeStatuses[0] + f.probeStatuses = f.probeStatuses[1:] + return status, nil +} + +func TestProjectUseCaseCreateAndUsage(t *testing.T) { + repo := &fakeProjectRepo{plan: "pro"} + jobRepo := &fakeJobRepo{} + gateway := &fakeAgentlandGateway{} + useCase := NewProjectUsecase(repo, jobRepo, gateway).(*projectUseCase) + now := time.Date(2026, 3, 12, 12, 0, 0, 0, time.UTC) + useCase.now = func() time.Time { return now } + principal := models.AuthPrincipal{UserID: "u_123"} + + createResp, apiErr := useCase.Create(context.Background(), principal, &models.ProjectCreateReq{Name: " Demo ", Template: "blank"}) + require.Nil(t, apiErr) + require.Equal(t, "Demo", createResp.Name) + require.Equal(t, "DRAFT", createResp.Status) + + usageResp, apiErr := useCase.Usage(context.Background(), principal) + require.Nil(t, apiErr) + require.Equal(t, 1, usageResp.Used) + require.Equal(t, 100, usageResp.Limit) +} + +func TestProjectUseCaseUpdateValidation(t *testing.T) { + repo := &fakeProjectRepo{projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "One", Status: "DRAFT", CreatedAt: time.Now(), UpdatedAt: time.Now()}, + }} + useCase := NewProjectUsecase(repo, &fakeJobRepo{}, &fakeAgentlandGateway{}) + principal := models.AuthPrincipal{UserID: "u_123"} + + _, apiErr := useCase.Update(context.Background(), principal, "p_1", &models.ProjectUpdateReq{Metadata: &models.ProjectMetadata{LastViewMode: "grid"}}) + require.NotNil(t, apiErr) + require.Equal(t, 400, apiErr.StatusCode) + + resp, apiErr := useCase.Update(context.Background(), principal, "p_1", &models.ProjectUpdateReq{Metadata: &models.ProjectMetadata{LastViewMode: "code"}}) + require.Nil(t, apiErr) + require.NotNil(t, resp.Metadata) + require.Equal(t, "code", resp.Metadata.LastViewMode) +} + +func TestProjectUseCaseDetailDeleteAndSharedView(t *testing.T) { + now := time.Date(2026, 3, 12, 12, 0, 0, 0, time.UTC) + repo := &fakeProjectRepo{projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "One", Status: "DRAFT", CreatedAt: now, UpdatedAt: now}, + }} + useCase := NewProjectUsecase(repo, &fakeJobRepo{}, &fakeAgentlandGateway{}).(*projectUseCase) + useCase.now = func() time.Time { return now.Add(5 * time.Minute) } + principal := models.AuthPrincipal{UserID: "u_123"} + + listResp, apiErr := useCase.List(context.Background(), principal, &models.ProjectListReq{View: "shared"}) + require.Nil(t, apiErr) + require.Len(t, listResp.Items, 0) + + detailResp, apiErr := useCase.Detail(context.Background(), principal, "p_1") + require.Nil(t, apiErr) + require.Equal(t, "u_123", detailResp.OwnerID) + require.NotEmpty(t, detailResp.LastOpenedAt) + + deleteResp, apiErr := useCase.Delete(context.Background(), principal, "p_1") + require.Nil(t, apiErr) + require.True(t, deleteResp.Success) + + _, apiErr = useCase.Detail(context.Background(), principal, "p_1") + require.NotNil(t, apiErr) + require.Equal(t, 404, apiErr.StatusCode) +} + +func TestProjectUseCaseCreateGenerationSuccess(t *testing.T) { + now := time.Date(2026, 3, 12, 13, 0, 0, 0, time.UTC) + repo := &fakeProjectRepo{projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "Demo", Status: "DRAFT", CreatedAt: now, UpdatedAt: now}, + }} + jobRepo := &fakeJobRepo{} + gateway := &fakeAgentlandGateway{events: []*models.AgentSSEEvent{ + {Event: "route", Data: []byte(`{"intent":"task","reason":"implementation"}`)}, + {Event: "session", Data: []byte(`{"session_id":"task-demo","workspace_path":"/workspace"}`)}, + {Event: "assistant_delta", Data: []byte(`{"content":"building ui"}`)}, + {Event: "done", Data: []byte(`{"session_id":"task-demo","status":"complete","iteration":1}`)}, + }} + useCase := NewProjectUsecase(repo, jobRepo, gateway).(*projectUseCase) + useCase.now = func() time.Time { return now } + useCase.runAsync = func(fn func()) { fn() } + principal := models.AuthPrincipal{UserID: "u_123"} + + resp, apiErr := useCase.CreateGeneration(context.Background(), principal, "p_1", &models.GenerationCreateReq{Prompt: "Build a dashboard", Deep: true}) + require.Nil(t, apiErr) + require.Equal(t, "QUEUED", resp.Status) + + job, err := jobRepo.GetJobByID(context.Background(), "u_123", resp.JobID) + require.NoError(t, err) + require.Equal(t, "SUCCESS", job.Status) + require.Equal(t, 100, job.Progress) + require.Contains(t, job.Logs, "Generation complete") + result, ok := job.Result.(map[string]any) + require.True(t, ok) + require.Equal(t, "p_1", result["project_id"]) + require.Equal(t, "DRAFT", repo.projects["p_1"].Status) + require.Contains(t, repo.statusTransitions, "BUILDING") + require.Len(t, repo.chatMessages["p_1"], 2) + require.Equal(t, "user", repo.chatMessages["p_1"][0].Role) + require.Equal(t, "Build a dashboard", repo.chatMessages["p_1"][0].Content) + require.Equal(t, "assistant", repo.chatMessages["p_1"][1].Role) + require.Equal(t, "building ui", repo.chatMessages["p_1"][1].Content) + require.True(t, repo.chatMessages["p_1"][1].CreatedAt.After(repo.chatMessages["p_1"][0].CreatedAt)) + require.Equal(t, "sess_gateway_1", repo.chatSessions["p_1"].GatewaySessionID) + require.Equal(t, "task-demo", repo.chatSessions["p_1"].AgentChatSessionID) + require.Equal(t, 1, gateway.ensureCalls) + require.NotNil(t, gateway.lastStreamReq) + require.True(t, gateway.lastStreamReq.Deep) +} + +func TestProjectUseCaseCreateGenerationFailure(t *testing.T) { + now := time.Date(2026, 3, 12, 13, 30, 0, 0, time.UTC) + repo := &fakeProjectRepo{projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "Demo", Status: "DRAFT", CreatedAt: now, UpdatedAt: now}, + }} + jobRepo := &fakeJobRepo{} + gateway := &fakeAgentlandGateway{ensureErr: errors.New("gateway unavailable")} + useCase := NewProjectUsecase(repo, jobRepo, gateway).(*projectUseCase) + useCase.now = func() time.Time { return now } + useCase.runAsync = func(fn func()) { fn() } + principal := models.AuthPrincipal{UserID: "u_123"} + + resp, apiErr := useCase.CreateGeneration(context.Background(), principal, "p_1", &models.GenerationCreateReq{Prompt: "Build a dashboard"}) + require.Nil(t, apiErr) + + job, err := jobRepo.GetJobByID(context.Background(), "u_123", resp.JobID) + require.NoError(t, err) + require.Equal(t, "FAILED", job.Status) + require.Contains(t, job.ErrorMessage, "gateway unavailable") + require.Equal(t, "DRAFT", repo.projects["p_1"].Status) +} + +func TestProjectUseCaseListMessages(t *testing.T) { + now := time.Date(2026, 3, 13, 10, 0, 0, 0, time.UTC) + repo := &fakeProjectRepo{ + projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "Demo", Status: "DRAFT", CreatedAt: now, UpdatedAt: now}, + }, + chatMessages: map[string][]*models.ProjectChatMessage{ + "p_1": { + {ID: "m_1", ProjectID: "p_1", OwnerID: "u_123", Role: "user", Content: "hello", CreatedAt: now}, + {ID: "m_2", ProjectID: "p_1", OwnerID: "u_123", Role: "assistant", Content: "hi", CreatedAt: now.Add(time.Minute)}, + }, + }, + } + useCase := NewProjectUsecase(repo, &fakeJobRepo{}, &fakeAgentlandGateway{}) + resp, apiErr := useCase.ListMessages(context.Background(), models.AuthPrincipal{UserID: "u_123"}, "p_1", &models.ChatMessagesReq{}) + require.Nil(t, apiErr) + require.Len(t, resp.Items, 2) + require.Equal(t, "hello", resp.Items[0].Content) + require.Equal(t, "assistant", resp.Items[1].Role) +} + +func TestProjectUseCaseCreateMessage(t *testing.T) { + now := time.Date(2026, 3, 13, 11, 0, 0, 0, time.UTC) + repo := &fakeProjectRepo{projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "Demo", Status: "DRAFT", CreatedAt: now, UpdatedAt: now}, + }} + jobRepo := &fakeJobRepo{jobs: map[string]*models.Job{ + "job_gen_1": { + ID: "job_gen_1", + OwnerID: "u_123", + ProjectID: "p_1", + Type: "APP_GENERATION", + Status: "SUCCESS", + GatewaySessionID: "gw_123", + AgentSessionID: "agent_123", + WorkspacePath: "/workspace", + UpdatedAt: now, + }, + }} + gateway := &fakeAgentlandGateway{events: []*models.AgentSSEEvent{ + {Event: "assistant_delta", Data: []byte(`{"content":"hello"}`)}, + {Event: "assistant_delta", Data: []byte(`{"content":" world"}`)}, + {Event: "done", Data: []byte(`{"status":"complete"}`)}, + }} + useCase := NewProjectUsecase(repo, jobRepo, gateway).(*projectUseCase) + useCase.now = func() time.Time { return now } + deltas := make([]string, 0) + resp, err := useCase.CreateMessage(context.Background(), models.AuthPrincipal{UserID: "u_123"}, "p_1", &models.ChatMessageCreateReq{Content: "Say hi", Deep: true}, func(delta string) error { + deltas = append(deltas, delta) + return nil + }) + require.NoError(t, err) + require.NotEmpty(t, resp.MessageID) + require.Equal(t, []string{"hello", " world"}, deltas) + require.Len(t, repo.chatMessages["p_1"], 2) + require.Equal(t, "user", repo.chatMessages["p_1"][0].Role) + require.Equal(t, "assistant", repo.chatMessages["p_1"][1].Role) + require.Equal(t, "hello world", repo.chatMessages["p_1"][1].Content) + require.Equal(t, "gw_123", repo.chatSessions["p_1"].GatewaySessionID) + require.Equal(t, "agent_123", repo.chatSessions["p_1"].AgentChatSessionID) + require.Equal(t, 0, gateway.ensureCalls) + require.NotNil(t, gateway.lastStreamReq) + require.True(t, gateway.lastStreamReq.Deep) +} + +func TestProjectUseCaseFileTreeAndPreview(t *testing.T) { + now := time.Date(2026, 3, 13, 12, 0, 0, 0, time.UTC) + repo := &fakeProjectRepo{ + projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "Demo", Status: "DRAFT", CreatedAt: now, UpdatedAt: now}, + }, + chatSessions: map[string]*models.ProjectChatSession{ + "p_1": { + ProjectID: "p_1", + OwnerID: "u_123", + GatewaySessionID: "gw_123", + AgentChatSessionID: "agent_123", + WorkspacePath: "/workspace", + CreatedAt: now, + UpdatedAt: now, + LastMessageAt: now, + }, + }, + } + gateway := &fakeAgentlandGateway{ + fsTree: &models.GatewayFSTreeResp{ + Root: "/workspace", + Nodes: []models.GatewayFSTreeNode{ + {Path: "src", Name: "src", Type: "dir"}, + {Path: "src/main.tsx", Name: "main.tsx", Type: "file", Size: 12}, + }, + }, + fsFiles: map[string]*models.GatewayFSFileResp{ + "/workspace/package.json": {Path: "/workspace/package.json", Encoding: "utf8", Content: `{"scripts":{"dev":"vite"}}`}, + }, + probeStatuses: []int{502, 200}, + } + useCase := NewProjectUsecase(repo, &fakeJobRepo{}, gateway).(*projectUseCase) + useCase.now = func() time.Time { return now } + principal := models.AuthPrincipal{UserID: "u_123"} + + treeResp, apiErr := useCase.FileTree(context.Background(), principal, "p_1", &models.FileTreeReq{Path: "/workspace", Depth: 3}) + require.Nil(t, apiErr) + require.Equal(t, "/workspace", treeResp.Root) + require.Len(t, treeResp.Nodes, 1) + require.Equal(t, "folder", treeResp.Nodes[0].Type) + require.Len(t, treeResp.Nodes[0].Children, 1) + require.Equal(t, "/workspace/src/main.tsx", treeResp.Nodes[0].Children[0].Path) + + previewResp, apiErr := useCase.StartPreview(context.Background(), principal, "p_1", &models.PreviewStartReq{Port: 3000}) + require.Nil(t, apiErr) + require.Equal(t, "RUNNING", previewResp.Status) + require.Equal(t, "/p/pv_123/", previewResp.PreviewURL) + require.Equal(t, 0, gateway.ensureCalls) +} + +func TestProjectUseCasePreviewSupportsStaticHTML(t *testing.T) { + now := time.Date(2026, 3, 14, 0, 10, 0, 0, time.UTC) + repo := &fakeProjectRepo{ + projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "Static Demo", Status: "DRAFT", CreatedAt: now, UpdatedAt: now}, + }, + chatSessions: map[string]*models.ProjectChatSession{ + "p_1": { + ProjectID: "p_1", + OwnerID: "u_123", + GatewaySessionID: "gw_123", + AgentChatSessionID: "agent_123", + WorkspacePath: "/workspace", + CreatedAt: now, + UpdatedAt: now, + LastMessageAt: now, + }, + }, + } + gateway := &fakeAgentlandGateway{ + fsTree: &models.GatewayFSTreeResp{ + Root: "/workspace", + Nodes: []models.GatewayFSTreeNode{ + {Path: "README.md", Name: "README.md", Type: "file", Size: 12}, + {Path: "package.json", Name: "package.json", Type: "file", Size: 12}, + {Path: "src", Name: "src", Type: "dir"}, + {Path: "src/index.html", Name: "index.html", Type: "file", Size: 12}, + }, + }, + fsFiles: map[string]*models.GatewayFSFileResp{ + "/workspace/package.json": {Path: "/workspace/package.json", Encoding: "utf8", Content: `{"name":"simple-node-frontend","scripts":{"start":"echo \"No build step required. Open src/index.html in a browser.\""}}`}, + }, + probeStatuses: []int{502, 200}, + } + useCase := NewProjectUsecase(repo, &fakeJobRepo{}, gateway).(*projectUseCase) + useCase.now = func() time.Time { return now } + + previewResp, apiErr := useCase.StartPreview(context.Background(), models.AuthPrincipal{UserID: "u_123"}, "p_1", &models.PreviewStartReq{Port: 3000}) + require.Nil(t, apiErr) + require.Equal(t, "RUNNING", previewResp.Status) + require.Contains(t, gateway.lastExecuteCode, "python3 -m http.server 3000 --bind 0.0.0.0") + require.Contains(t, gateway.lastExecuteCode, "cd '/workspace/src'") +} + +func TestProjectUseCaseWorkspaceRequiresExistingRuntime(t *testing.T) { + now := time.Date(2026, 3, 13, 12, 30, 0, 0, time.UTC) + repo := &fakeProjectRepo{projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "Demo", Status: "DRAFT", CreatedAt: now, UpdatedAt: now}, + }} + gateway := &fakeAgentlandGateway{} + useCase := NewProjectUsecase(repo, &fakeJobRepo{}, gateway).(*projectUseCase) + useCase.now = func() time.Time { return now } + principal := models.AuthPrincipal{UserID: "u_123"} + + _, apiErr := useCase.FileTree(context.Background(), principal, "p_1", &models.FileTreeReq{Path: "/workspace", Depth: 3}) + require.NotNil(t, apiErr) + require.Equal(t, 409, apiErr.StatusCode) + require.Equal(t, "runtime_unavailable", apiErr.Msg) + require.Equal(t, 0, gateway.ensureCalls) + + _, apiErr = useCase.StartPreview(context.Background(), principal, "p_1", &models.PreviewStartReq{Port: 3000}) + require.NotNil(t, apiErr) + require.Equal(t, 409, apiErr.StatusCode) + require.Equal(t, "runtime_unavailable", apiErr.Msg) + require.Equal(t, 0, gateway.ensureCalls) +} + +func TestProjectUseCaseCreateMessageRequiresExistingRuntime(t *testing.T) { + now := time.Date(2026, 3, 13, 12, 45, 0, 0, time.UTC) + repo := &fakeProjectRepo{projects: map[string]*models.Project{ + "p_1": {ID: "p_1", OwnerID: "u_123", Name: "Demo", Status: "DRAFT", CreatedAt: now, UpdatedAt: now}, + }} + gateway := &fakeAgentlandGateway{} + useCase := NewProjectUsecase(repo, &fakeJobRepo{}, gateway).(*projectUseCase) + useCase.now = func() time.Time { return now } + + _, err := useCase.CreateMessage(context.Background(), models.AuthPrincipal{UserID: "u_123"}, "p_1", &models.ChatMessageCreateReq{Content: "Say hi"}, nil) + require.Error(t, err) + require.Equal(t, "runtime_unavailable", err.Error()) + require.Equal(t, 0, gateway.ensureCalls) + _, hasSession := repo.chatSessions["p_1"] + require.False(t, hasSession) +} diff --git a/app/be/internal/biz/project_workspace.go b/app/be/internal/biz/project_workspace.go new file mode 100644 index 0000000..9d14677 --- /dev/null +++ b/app/be/internal/biz/project_workspace.go @@ -0,0 +1,686 @@ +package biz + +import ( + "archive/zip" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "path" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/autherr" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/token" +) + +const ( + defaultWorkspaceRoot = "/workspace" + defaultWorkspaceTreeDepth = 3 + maxWorkspaceTreeDepth = 20 + defaultPreviewPort = 3000 + previewReadinessTimeout = 20 * time.Second + previewReadinessPollInterval = 2 * time.Second + previewContextLanguage = "bash" + previewExecTimeoutMs = 120000 +) + +type workspaceTreeNode struct { + Path string + Name string + Type string + Size int64 + Children []*workspaceTreeNode +} + +type previewPackageJSON struct { + Scripts map[string]string `json:"scripts"` + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +func (u *projectUseCase) FileTree(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.FileTreeReq) (*models.FileTreeResp, *response.APIError) { + depth := req.Depth + if depth <= 0 { + depth = defaultWorkspaceTreeDepth + } + if depth > maxWorkspaceTreeDepth { + depth = maxWorkspaceTreeDepth + } + rootPath := normalizedWorkspacePath(req.Path) + return withWorkspaceSessionRetry(u, ctx, principal, projectID, func(project *models.Project, session *models.ProjectChatSession) (*models.FileTreeResp, error) { + _ = project + fsTree, err := u.gateway.GetFSTree(ctx, session.GatewaySessionID, rootPath, depth) + if err != nil { + return nil, err + } + return &models.FileTreeResp{ + Root: firstNonEmpty(strings.TrimSpace(fsTree.Root), rootPath), + Nodes: buildWorkspaceFileNodes(firstNonEmpty(strings.TrimSpace(fsTree.Root), rootPath), fsTree.Nodes), + }, nil + }) +} + +func (u *projectUseCase) FileContent(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.FileContentReq) (*models.FileContentResp, *response.APIError) { + targetPath := normalizedWorkspacePath(req.Path) + return withWorkspaceSessionRetry(u, ctx, principal, projectID, func(project *models.Project, session *models.ProjectChatSession) (*models.FileContentResp, error) { + _ = project + fileResp, err := u.gateway.GetFSFile(ctx, session.GatewaySessionID, targetPath, "utf8") + if err != nil { + return nil, err + } + return &models.FileContentResp{ + Path: strings.TrimSpace(fileResp.Path), + Language: detectFileLanguage(fileResp.Path), + Content: fileResp.Content, + SHA: "", + }, nil + }) +} + +func (u *projectUseCase) Download(ctx context.Context, principal models.AuthPrincipal, projectID string) (*models.WorkspaceArchive, *response.APIError) { + return withWorkspaceSessionRetry(u, ctx, principal, projectID, func(project *models.Project, session *models.ProjectChatSession) (*models.WorkspaceArchive, error) { + fsTree, err := u.gateway.GetFSTree(ctx, session.GatewaySessionID, defaultWorkspaceRoot, maxWorkspaceTreeDepth) + if err != nil { + return nil, err + } + root := firstNonEmpty(strings.TrimSpace(fsTree.Root), defaultWorkspaceRoot) + filePaths := collectWorkspaceFilePaths(root, fsTree.Nodes) + + buffer := bytes.NewBuffer(nil) + zipWriter := zip.NewWriter(buffer) + for _, filePath := range filePaths { + fileResp, readErr := u.gateway.GetFSFile(ctx, session.GatewaySessionID, filePath, "base64") + if readErr != nil { + return nil, readErr + } + payload, decodeErr := base64.StdEncoding.DecodeString(fileResp.Content) + if decodeErr != nil { + return nil, response.InternalError() + } + entryName := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(fileResp.Path), root), "/") + if entryName == "" { + entryName = path.Base(strings.TrimSpace(fileResp.Path)) + } + entryWriter, createErr := zipWriter.Create(entryName) + if createErr != nil { + return nil, response.InternalError() + } + if _, writeErr := entryWriter.Write(payload); writeErr != nil { + return nil, response.InternalError() + } + } + if err := zipWriter.Close(); err != nil { + return nil, response.InternalError() + } + return &models.WorkspaceArchive{ + FileName: buildArchiveFileName(project.Name, project.ID), + ContentType: "application/zip", + Content: buffer.Bytes(), + }, nil + }) +} + +func (u *projectUseCase) StartPreview(ctx context.Context, principal models.AuthPrincipal, projectID string, req *models.PreviewStartReq) (*models.PreviewStartResp, *response.APIError) { + port := req.Port + if port <= 0 || port > 65535 { + port = defaultPreviewPort + } + return withWorkspaceSessionRetry(u, ctx, principal, projectID, func(project *models.Project, session *models.ProjectChatSession) (*models.PreviewStartResp, error) { + status, previewInfo, err := u.ensureProjectPreview(ctx, project, session, port) + if err != nil { + return nil, err + } + resp := &models.PreviewStartResp{Status: status} + if previewInfo != nil { + resp.PreviewID = previewInfo.PreviewToken + resp.PreviewURL = previewInfo.PreviewURL + } + return resp, nil + }) +} + +func (u *projectUseCase) PreviewStatus(ctx context.Context, principal models.AuthPrincipal, projectID string) (*models.PreviewStatusResp, *response.APIError) { + return withWorkspaceSessionRetry(u, ctx, principal, projectID, func(project *models.Project, session *models.ProjectChatSession) (*models.PreviewStatusResp, error) { + status, previewInfo, err := u.ensureProjectPreview(ctx, project, session, defaultPreviewPort) + if err != nil { + return nil, err + } + resp := &models.PreviewStatusResp{Status: status} + if previewInfo != nil { + resp.PreviewID = previewInfo.PreviewToken + resp.PreviewURL = previewInfo.PreviewURL + if !previewInfo.ExpiresAt.IsZero() { + resp.LastHeartbeatAt = previewInfo.ExpiresAt.UTC().Format(time.RFC3339) + } + } + return resp, nil + }) +} + +func (u *projectUseCase) projectSessionMutex(ownerID, projectID string) *sync.Mutex { + key := strings.TrimSpace(ownerID) + ":" + strings.TrimSpace(projectID) + mutex, _ := u.sessionLocks.LoadOrStore(key, &sync.Mutex{}) + return mutex.(*sync.Mutex) +} + +func (u *projectUseCase) loadProjectRuntime(ctx context.Context, principal models.AuthPrincipal, projectID string) (*models.Project, *models.ProjectChatSession, *response.APIError) { + if strings.TrimSpace(principal.UserID) == "" { + return nil, nil, response.UnauthorizedError() + } + project, err := u.repo.GetProjectByID(ctx, principal.UserID, strings.TrimSpace(projectID)) + if err != nil { + return nil, nil, u.apiError(err) + } + session, err := u.ensureProjectChatSession(ctx, principal.UserID, project) + if err != nil { + return nil, nil, gatewayAPIError(err) + } + return project, session, nil +} + +func withWorkspaceSessionRetry[T any](u *projectUseCase, ctx context.Context, principal models.AuthPrincipal, projectID string, operation func(project *models.Project, session *models.ProjectChatSession) (T, error)) (T, *response.APIError) { + var zero T + project, session, apiErr := u.loadProjectRuntime(ctx, principal, projectID) + if apiErr != nil { + return zero, apiErr + } + result, err := operation(project, session) + if err == nil { + return result, nil + } + if !shouldRecoverWorkspaceSession(err) { + return zero, gatewayAPIError(err) + } + refreshedSession, refreshErr := u.refreshProjectChatSession(ctx, principal.UserID, project, session) + if refreshErr != nil { + return zero, gatewayAPIError(refreshErr) + } + result, err = operation(project, refreshedSession) + if err != nil { + return zero, gatewayAPIError(err) + } + return result, nil +} + +func shouldRecoverWorkspaceSession(err error) bool { + var gatewayErr *models.GatewayResponseError + if !errors.As(err, &gatewayErr) { + return false + } + return gatewayErr.StatusCode == 401 || gatewayErr.StatusCode == 404 +} + +func (u *projectUseCase) refreshProjectChatSession(ctx context.Context, ownerID string, project *models.Project, previous *models.ProjectChatSession) (*models.ProjectChatSession, error) { + mutex := u.projectSessionMutex(ownerID, project.ID) + mutex.Lock() + defer mutex.Unlock() + + current, err := u.repo.GetProjectChatSession(ctx, ownerID, project.ID) + if err != nil { + return nil, err + } + if current != nil && previous != nil && strings.TrimSpace(current.GatewaySessionID) != "" && strings.TrimSpace(current.GatewaySessionID) != strings.TrimSpace(previous.GatewaySessionID) { + return current, nil + } + + job, err := u.latestProjectRuntime(ctx, ownerID, project.ID) + if err != nil { + return nil, err + } + seedGatewaySessionID := strings.TrimSpace(job.GatewaySessionID) + if seedGatewaySessionID == "" || (previous != nil && seedGatewaySessionID == strings.TrimSpace(previous.GatewaySessionID)) { + return nil, autherr.ErrProjectRuntimeUnavailable + } + seedAgentChatSessionID := strings.TrimSpace(job.AgentSessionID) + seedWorkspacePath := strings.TrimSpace(job.WorkspacePath) + if seedAgentChatSessionID == "" { + if current != nil && strings.TrimSpace(current.AgentChatSessionID) != "" { + seedAgentChatSessionID = strings.TrimSpace(current.AgentChatSessionID) + } else if previous != nil && strings.TrimSpace(previous.AgentChatSessionID) != "" { + seedAgentChatSessionID = strings.TrimSpace(previous.AgentChatSessionID) + } else { + seedAgentChatSessionID = token.NewID("chat") + } + } + if seedWorkspacePath == "" { + if current != nil && strings.TrimSpace(current.WorkspacePath) != "" { + seedWorkspacePath = strings.TrimSpace(current.WorkspacePath) + } else if previous != nil && strings.TrimSpace(previous.WorkspacePath) != "" { + seedWorkspacePath = strings.TrimSpace(previous.WorkspacePath) + } else { + seedWorkspacePath = defaultGenerationWorkspace + } + } + return u.repo.UpsertProjectChatSession(ctx, &models.UpsertProjectChatSessionInput{ + ProjectID: project.ID, + OwnerID: ownerID, + GatewaySessionID: seedGatewaySessionID, + AgentChatSessionID: seedAgentChatSessionID, + WorkspacePath: seedWorkspacePath, + Now: u.now().UTC(), + }) +} + +func (u *projectUseCase) ensureProjectPreview(ctx context.Context, project *models.Project, session *models.ProjectChatSession, port int) (string, *models.GatewayPreviewInfo, error) { + _ = project + if previewPortReady(ctx, u.gateway, session.GatewaySessionID, port) { + previewInfo, err := u.gateway.CreatePreview(ctx, session.GatewaySessionID, port) + if err != nil { + return "", nil, err + } + return "RUNNING", previewInfo, nil + } + + startCommand, err := u.resolvePreviewStartCommand(ctx, session) + if err != nil { + return "", nil, err + } + ctxInfo, err := u.gateway.CreateExecContext(ctx, session.GatewaySessionID, previewContextLanguage, firstNonEmpty(strings.TrimSpace(session.WorkspacePath), defaultWorkspaceRoot)) + if err != nil { + return "", nil, err + } + _, err = u.gateway.ExecuteInContext(ctx, session.GatewaySessionID, ctxInfo.ContextID, startCommand, previewExecTimeoutMs) + if err != nil { + return "", nil, err + } + + readyCtx, cancel := context.WithTimeout(ctx, previewReadinessTimeout) + defer cancel() + for { + if previewPortReady(readyCtx, u.gateway, session.GatewaySessionID, port) { + previewInfo, previewErr := u.gateway.CreatePreview(readyCtx, session.GatewaySessionID, port) + if previewErr != nil { + return "", nil, previewErr + } + return "RUNNING", previewInfo, nil + } + select { + case <-readyCtx.Done(): + previewInfo, previewErr := u.gateway.CreatePreview(ctx, session.GatewaySessionID, port) + if previewErr != nil { + return "STARTING", nil, nil + } + return "STARTING", previewInfo, nil + case <-time.After(previewReadinessPollInterval): + } + } +} + +func (u *projectUseCase) resolvePreviewStartCommand(ctx context.Context, session *models.ProjectChatSession) (string, error) { + workspacePath := firstNonEmpty(strings.TrimSpace(session.WorkspacePath), defaultWorkspaceRoot) + packageManager := "npm" + staticRoot := "" + if tree, treeErr := u.gateway.GetFSTree(ctx, session.GatewaySessionID, workspacePath, 2); treeErr == nil { + packageManager = previewPackageManager(tree.Nodes) + staticRoot = previewStaticRoot(workspacePath, tree.Nodes) + } + + packageJSON, err := u.gateway.GetFSFile(ctx, session.GatewaySessionID, path.Join(workspacePath, "package.json"), "utf8") + if err == nil { + var parsed previewPackageJSON + if err := json.Unmarshal([]byte(packageJSON.Content), &parsed); err != nil { + return "", &models.GatewayResponseError{StatusCode: 500, Message: "invalid package.json in workspace"} + } + if staticRoot != "" && shouldServeStaticPreview(parsed) { + return previewStaticServeCommand(staticRoot), nil + } + installCommand := previewInstallCommand(packageManager) + scriptCommand := previewScriptCommand(packageManager, parsed.Scripts) + if scriptCommand != "" { + return strings.TrimSpace(strings.Join([]string{ + "set -e", + "cd " + shellQuote(workspacePath), + "if command -v lsof >/dev/null 2>&1 && lsof -iTCP:" + shellQuoteInt(defaultPreviewPort) + " -sTCP:LISTEN >/dev/null 2>&1; then exit 0; fi", + "if [ ! -d node_modules ]; then " + installCommand + "; fi", + "nohup sh -lc " + shellQuote(scriptCommand) + " >/tmp/agentland-preview.log 2>&1 &", + "sleep 1", + }, "\n")), nil + } + if staticRoot != "" { + return previewStaticServeCommand(staticRoot), nil + } + return "", &models.GatewayResponseError{StatusCode: 400, Message: "workspace package.json is missing dev/start script"} + } + if isGatewayStatus(err, 404) && staticRoot != "" { + return previewStaticServeCommand(staticRoot), nil + } + return "", err +} + +func previewPortReady(ctx context.Context, gateway AgentlandGateway, gatewaySessionID string, port int) bool { + probeCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + statusCode, err := gateway.ProbePort(probeCtx, gatewaySessionID, port, "/") + if err != nil { + return false + } + return statusCode > 0 && statusCode != 502 && statusCode != 503 && statusCode != 504 +} + +func previewPackageManager(nodes []models.GatewayFSTreeNode) string { + for _, node := range nodes { + name := strings.ToLower(strings.TrimSpace(node.Name)) + switch name { + case "pnpm-lock.yaml": + return "pnpm" + case "yarn.lock": + return "yarn" + } + } + return "npm" +} + +func previewInstallCommand(packageManager string) string { + switch packageManager { + case "pnpm": + return "corepack enable >/dev/null 2>&1 || true; pnpm install" + case "yarn": + return "corepack enable >/dev/null 2>&1 || true; yarn install" + default: + return "npm install" + } +} + +func previewScriptCommand(packageManager string, scripts map[string]string) string { + if len(scripts) == 0 { + return "" + } + hasDev := strings.TrimSpace(scripts["dev"]) != "" + hasStart := strings.TrimSpace(scripts["start"]) != "" + switch packageManager { + case "pnpm": + if hasDev { + return "pnpm run dev -- --host 0.0.0.0 --port 3000" + } + if hasStart { + return "pnpm run start -- --host 0.0.0.0 --port 3000" + } + case "yarn": + if hasDev { + return "yarn dev --host 0.0.0.0 --port 3000" + } + if hasStart { + return "yarn start --host 0.0.0.0 --port 3000" + } + default: + if hasDev { + return "npm run dev -- --host 0.0.0.0 --port 3000" + } + if hasStart { + return "npm run start -- --host 0.0.0.0 --port 3000" + } + } + return "" +} + +func shouldServeStaticPreview(pkg previewPackageJSON) bool { + return strings.TrimSpace(pkg.Scripts["dev"]) == "" && len(pkg.Dependencies) == 0 && len(pkg.DevDependencies) == 0 +} + +func previewStaticRoot(workspacePath string, nodes []models.GatewayFSTreeNode) string { + if len(nodes) == 0 { + return "" + } + normalized := make(map[string]struct{}, len(nodes)) + for _, node := range nodes { + if mapWorkspaceNodeType(node.Type) != "file" { + continue + } + normalized[normalizeTreeNodePath(workspacePath, node.Path)] = struct{}{} + } + candidates := []string{ + path.Join(workspacePath, "index.html"), + path.Join(workspacePath, "src/index.html"), + path.Join(workspacePath, "public/index.html"), + } + for _, candidate := range candidates { + if _, ok := normalized[path.Clean(candidate)]; ok { + return path.Dir(path.Clean(candidate)) + } + } + for itemPath := range normalized { + if strings.HasSuffix(strings.ToLower(itemPath), "/index.html") { + return path.Dir(itemPath) + } + } + for itemPath := range normalized { + if path.Ext(strings.ToLower(itemPath)) == ".html" && path.Dir(itemPath) == path.Clean(workspacePath) { + return path.Clean(workspacePath) + } + } + return "" +} + +func previewStaticServeCommand(root string) string { + return strings.TrimSpace(strings.Join([]string{ + "set -e", + "cd " + shellQuote(root), + "if command -v lsof >/dev/null 2>&1 && lsof -iTCP:" + shellQuoteInt(defaultPreviewPort) + " -sTCP:LISTEN >/dev/null 2>&1; then exit 0; fi", + "nohup sh -lc " + shellQuote("python3 -m http.server 3000 --bind 0.0.0.0") + " >/tmp/agentland-preview.log 2>&1 &", + "sleep 1", + }, "\n")) +} + +func isGatewayStatus(err error, statusCode int) bool { + var gatewayErr *models.GatewayResponseError + return errors.As(err, &gatewayErr) && gatewayErr.StatusCode == statusCode +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func shellQuoteInt(value int) string { + return strconv.Itoa(value) +} + +func normalizedWorkspacePath(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return defaultWorkspaceRoot + } + if strings.HasPrefix(trimmed, "/") { + return path.Clean(trimmed) + } + return path.Clean(path.Join(defaultWorkspaceRoot, trimmed)) +} + +func gatewayAPIError(err error) *response.APIError { + if err == nil { + return nil + } + if errors.Is(err, autherr.ErrProjectRuntimeUnavailable) { + return response.RuntimeUnavailableError() + } + var gatewayErr *models.GatewayResponseError + if errorsAs(err, &gatewayErr) { + switch gatewayErr.StatusCode { + case 400: + return response.InvalidArgumentError("request", firstNonEmpty(strings.TrimSpace(gatewayErr.Message), "invalid gateway request")) + case 401: + return response.RuntimeUnavailableError() + case 404: + return response.NotFoundError() + } + } + return response.InternalError() +} + +func buildWorkspaceFileNodes(root string, rawNodes []models.GatewayFSTreeNode) []models.FileNode { + sorted := append([]models.GatewayFSTreeNode{}, rawNodes...) + sort.Slice(sorted, func(i, j int) bool { + leftPath := normalizeTreeNodePath(root, sorted[i].Path) + rightPath := normalizeTreeNodePath(root, sorted[j].Path) + leftDepth := strings.Count(strings.TrimPrefix(leftPath, root), "/") + rightDepth := strings.Count(strings.TrimPrefix(rightPath, root), "/") + if leftDepth != rightDepth { + return leftDepth < rightDepth + } + return leftPath < rightPath + }) + index := map[string]*workspaceTreeNode{} + roots := make([]*workspaceTreeNode, 0) + for _, item := range sorted { + nodeType := mapWorkspaceNodeType(item.Type) + if nodeType == "" { + continue + } + absolutePath := normalizeTreeNodePath(root, item.Path) + node := &workspaceTreeNode{Path: absolutePath, Name: item.Name, Type: nodeType, Size: item.Size} + index[absolutePath] = node + parentPath := path.Dir(absolutePath) + if parentPath == "." || parentPath == "/" || parentPath == root { + roots = append(roots, node) + continue + } + parent, ok := index[parentPath] + if !ok { + roots = append(roots, node) + continue + } + parent.Children = append(parent.Children, node) + } + sortWorkspaceTree(roots) + result := make([]models.FileNode, 0, len(roots)) + for _, node := range roots { + result = append(result, convertWorkspaceNode(node)) + } + return result +} + +func sortWorkspaceTree(nodes []*workspaceTreeNode) { + sort.Slice(nodes, func(i, j int) bool { + if nodes[i].Type != nodes[j].Type { + return nodes[i].Type == "folder" + } + return strings.ToLower(nodes[i].Name) < strings.ToLower(nodes[j].Name) + }) + for _, node := range nodes { + if len(node.Children) > 0 { + sortWorkspaceTree(node.Children) + } + } +} + +func convertWorkspaceNode(node *workspaceTreeNode) models.FileNode { + converted := models.FileNode{ + Path: node.Path, + Name: node.Name, + Type: node.Type, + Size: node.Size, + } + if len(node.Children) > 0 { + converted.Children = make([]models.FileNode, 0, len(node.Children)) + for _, child := range node.Children { + converted.Children = append(converted.Children, convertWorkspaceNode(child)) + } + } + return converted +} + +func collectWorkspaceFilePaths(root string, rawNodes []models.GatewayFSTreeNode) []string { + paths := make([]string, 0) + for _, item := range rawNodes { + if mapWorkspaceNodeType(item.Type) != "file" { + continue + } + paths = append(paths, normalizeTreeNodePath(root, item.Path)) + } + sort.Strings(paths) + return paths +} + +func normalizeTreeNodePath(root, itemPath string) string { + trimmedRoot := firstNonEmpty(strings.TrimSpace(root), defaultWorkspaceRoot) + trimmedPath := strings.TrimSpace(itemPath) + if trimmedPath == "" { + return trimmedRoot + } + if strings.HasPrefix(trimmedPath, "/") { + return path.Clean(trimmedPath) + } + return path.Clean(path.Join(trimmedRoot, trimmedPath)) +} + +func mapWorkspaceNodeType(nodeType string) string { + switch strings.ToLower(strings.TrimSpace(nodeType)) { + case "dir", "folder": + return "folder" + case "file": + return "file" + default: + return "" + } +} + +func detectFileLanguage(filePath string) string { + switch strings.ToLower(path.Ext(strings.TrimSpace(filePath))) { + case ".ts", ".tsx": + return "typescript" + case ".js", ".jsx", ".mjs", ".cjs": + return "javascript" + case ".json": + return "json" + case ".md": + return "markdown" + case ".html": + return "html" + case ".css": + return "css" + case ".go": + return "go" + case ".py": + return "python" + case ".yml", ".yaml": + return "yaml" + case ".sh": + return "shell" + default: + return "plaintext" + } +} + +func buildArchiveFileName(projectName, projectID string) string { + base := strings.ToLower(strings.TrimSpace(projectName)) + if base == "" { + base = strings.TrimSpace(projectID) + } + base = strings.Map(func(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return r + case r >= '0' && r <= '9': + return r + case r == '-' || r == '_': + return r + default: + return '-' + } + }, base) + base = strings.Trim(base, "-") + if base == "" { + base = strings.TrimSpace(projectID) + } + return base + ".zip" +} + +func errorsAs(err error, target any) bool { + switch t := target.(type) { + case **models.GatewayResponseError: + gatewayErr, ok := err.(*models.GatewayResponseError) + if !ok { + return false + } + *t = gatewayErr + return true + default: + return false + } +} diff --git a/app/be/internal/data/agent_gateway.go b/app/be/internal/data/agent_gateway.go new file mode 100644 index 0000000..199c6b9 --- /dev/null +++ b/app/be/internal/data/agent_gateway.go @@ -0,0 +1,494 @@ +package data + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/models" + commonmodels "github.com/Fl0rencess720/agentland/pkg/common/models" + "github.com/spf13/viper" +) + +const ( + agentlandSessionHeader = "x-agentland-session" + agentlandAgentRuntime = "agentland-agent" + agentlandAgentRuntimeNS = "agentland-sandboxes" + agentlandKorokdPort = 1883 + agentlandHealthPollInterval = 2 * time.Second + agentlandHealthCheckPath = "/api/agent-sessions/invocations/health?runtime=" + agentlandAgentRuntime + "&runtime_namespace=" + agentlandAgentRuntimeNS + agentlandStreamChatPathFormat = "/api/agent-sessions/%s/endpoints/by-port/8000/v1/chat/stream" +) + +type gatewayEnvelope[T any] struct { + Msg string `json:"msg"` + Code int `json:"code"` + Data T `json:"data"` +} + +type agentlandGatewayClient struct { + baseURL string + httpClient *http.Client + streamHTTPClient *http.Client +} + +func NewAgentlandGatewayClient() biz.AgentlandGateway { + return &agentlandGatewayClient{ + baseURL: strings.TrimRight(strings.TrimSpace(viper.GetString("agentland-gateway.url")), "/"), + httpClient: &http.Client{ + Timeout: 65 * time.Second, + }, + streamHTTPClient: &http.Client{}, + } +} + +func (c *agentlandGatewayClient) EnsureSessionReady(ctx context.Context) (*models.AgentSessionInfo, error) { + if c.baseURL == "" { + return nil, fmt.Errorf("agentland-gateway.url is required") + } + var sessionID string + var lastErr error + for { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+agentlandHealthCheckPath, nil) + if err != nil { + return nil, err + } + if sessionID != "" { + req.Header.Set(agentlandSessionHeader, sessionID) + } + resp, err := c.httpClient.Do(req) + if err == nil { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + _ = resp.Body.Close() + if readErr != nil { + lastErr = readErr + } else { + if headerSessionID := strings.TrimSpace(resp.Header.Get(agentlandSessionHeader)); headerSessionID != "" { + sessionID = headerSessionID + } + if resp.StatusCode == http.StatusOK { + var payload struct { + Status string `json:"status"` + } + if json.Unmarshal(body, &payload) == nil && strings.EqualFold(payload.Status, "ok") && sessionID != "" { + return &models.AgentSessionInfo{GatewaySessionID: sessionID}, nil + } + lastErr = fmt.Errorf("gateway health check missing session or status: %s", strings.TrimSpace(string(body))) + } else { + lastErr = fmt.Errorf("gateway health status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + } + } else { + lastErr = err + } + select { + case <-ctx.Done(): + if lastErr != nil { + return nil, fmt.Errorf("ensure sandbox ready: %w", lastErr) + } + return nil, ctx.Err() + case <-time.After(agentlandHealthPollInterval): + } + } +} + +func (c *agentlandGatewayClient) StreamChat(ctx context.Context, gatewaySessionID string, reqBody *models.AgentChatStreamReq, onEvent func(*models.AgentSSEEvent) error) error { + if c.baseURL == "" { + return fmt.Errorf("agentland-gateway.url is required") + } + payload, err := json.Marshal(reqBody) + if err != nil { + return err + } + url := c.baseURL + fmt.Sprintf(agentlandStreamChatPathFormat, strings.TrimSpace(gatewaySessionID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.streamClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("stream chat status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return parseAgentSSE(resp.Body, onEvent) +} + +func (c *agentlandGatewayClient) streamClient() *http.Client { + if c.streamHTTPClient != nil { + return c.streamHTTPClient + } + if c.httpClient == nil { + return &http.Client{} + } + return &http.Client{ + Transport: c.httpClient.Transport, + CheckRedirect: c.httpClient.CheckRedirect, + Jar: c.httpClient.Jar, + } +} + +func (c *agentlandGatewayClient) CreatePreview(ctx context.Context, gatewaySessionID string, port int) (*models.GatewayPreviewInfo, error) { + payload := map[string]any{"port": port} + data, err := c.doJSON(ctx, http.MethodPost, "/api/previews", map[string]string{agentlandSessionHeader: strings.TrimSpace(gatewaySessionID)}, payload) + if err != nil { + return nil, err + } + var resp struct { + SessionID string `json:"session_id"` + Port int `json:"port"` + PreviewToken string `json:"preview_token"` + PreviewURL string `json:"preview_url"` + ExpiresAt time.Time `json:"expires_at"` + } + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &models.GatewayPreviewInfo{ + SessionID: strings.TrimSpace(resp.SessionID), + Port: resp.Port, + PreviewToken: strings.TrimSpace(resp.PreviewToken), + PreviewURL: normalizePreviewURL(resp.PreviewURL), + ExpiresAt: resp.ExpiresAt, + }, nil +} + +func normalizePreviewURL(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + parsed, err := url.Parse(trimmed) + if err != nil { + return trimmed + } + if !strings.HasPrefix(parsed.Path, "/p/") { + return trimmed + } + result := parsed.Path + if parsed.RawQuery != "" { + result += "?" + parsed.RawQuery + } + if parsed.Fragment != "" { + result += "#" + parsed.Fragment + } + return result +} + +func (c *agentlandGatewayClient) GetFSTree(ctx context.Context, gatewaySessionID, targetPath string, depth int) (*models.GatewayFSTreeResp, error) { + query := url.Values{} + query.Set("path", strings.TrimSpace(targetPath)) + if depth > 0 { + query.Set("depth", fmt.Sprintf("%d", depth)) + } + data, err := c.doJSON(ctx, http.MethodGet, "/api/code-runner/fs/tree?"+query.Encode(), map[string]string{agentlandSessionHeader: strings.TrimSpace(gatewaySessionID)}, nil) + if err != nil { + return nil, err + } + var resp commonmodels.GetFSTreeResp + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + nodes := make([]models.GatewayFSTreeNode, 0, len(resp.Nodes)) + for _, item := range resp.Nodes { + nodes = append(nodes, models.GatewayFSTreeNode{ + Path: item.Path, + Name: item.Name, + Type: item.Type, + Size: item.Size, + ModTime: item.ModTime, + }) + } + return &models.GatewayFSTreeResp{Root: resp.Root, Nodes: nodes}, nil +} + +func (c *agentlandGatewayClient) GetFSFile(ctx context.Context, gatewaySessionID, targetPath, encoding string) (*models.GatewayFSFileResp, error) { + query := url.Values{} + query.Set("path", strings.TrimSpace(targetPath)) + if strings.TrimSpace(encoding) != "" { + query.Set("encoding", strings.TrimSpace(encoding)) + } + data, err := c.doJSON(ctx, http.MethodGet, "/api/code-runner/fs/file?"+query.Encode(), map[string]string{agentlandSessionHeader: strings.TrimSpace(gatewaySessionID)}, nil) + if err != nil { + return nil, err + } + var resp commonmodels.GetFSFileResp + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &models.GatewayFSFileResp{ + Path: resp.Path, + Size: resp.Size, + Encoding: resp.Encoding, + Content: resp.Content, + }, nil +} + +func (c *agentlandGatewayClient) CreateExecContext(ctx context.Context, gatewaySessionID, language, cwd string) (*models.GatewayExecContextInfo, error) { + payload := commonmodels.CreateContextReq{Language: language, CWD: cwd} + data, err := c.doJSON(ctx, http.MethodPost, "/api/code-runner/contexts", map[string]string{agentlandSessionHeader: strings.TrimSpace(gatewaySessionID)}, payload) + if err != nil { + return nil, err + } + var resp commonmodels.CreateContextResp + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &models.GatewayExecContextInfo{ + ContextID: strings.TrimSpace(resp.ContextID), + Language: strings.TrimSpace(resp.Language), + CWD: strings.TrimSpace(resp.CWD), + State: strings.TrimSpace(resp.State), + CreatedAt: strings.TrimSpace(resp.CreatedAt), + }, nil +} + +func (c *agentlandGatewayClient) ExecuteInContext(ctx context.Context, gatewaySessionID, contextID, code string, timeoutMs int) (*models.GatewayExecutionResult, error) { + payload := commonmodels.ExecuteContextReq{Code: code, TimeoutMs: timeoutMs} + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + resp, err := c.doRaw(ctx, http.MethodPost, "/api/code-runner/contexts/"+url.PathEscape(strings.TrimSpace(contextID))+"/execute", map[string]string{"Content-Type": "application/json", agentlandSessionHeader: strings.TrimSpace(gatewaySessionID)}, bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return nil, decodeGatewayError(resp.StatusCode, bodyBytes) + } + return parseExecuteSSE(resp.Body) +} + +func (c *agentlandGatewayClient) ProbePort(ctx context.Context, gatewaySessionID string, port int, requestPath string) (int, error) { + if strings.TrimSpace(requestPath) == "" { + requestPath = "/" + } + resp, err := c.doRaw(ctx, http.MethodGet, fmt.Sprintf("/api/agent-sessions/%s/endpoints/by-port/%d%s", url.PathEscape(strings.TrimSpace(gatewaySessionID)), port, ensurePrefixedPath(requestPath)), nil, nil) + if err != nil { + return 0, err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 32<<10)) + return resp.StatusCode, nil +} + +func (c *agentlandGatewayClient) doJSON(ctx context.Context, method, requestPath string, headers map[string]string, body any) (json.RawMessage, error) { + var bodyReader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(payload) + if headers == nil { + headers = map[string]string{} + } + if _, ok := headers["Content-Type"]; !ok { + headers["Content-Type"] = "application/json" + } + } + resp, err := c.doRaw(ctx, method, requestPath, headers, bodyReader) + if err != nil { + return nil, err + } + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, decodeGatewayError(resp.StatusCode, bodyBytes) + } + var envelope gatewayEnvelope[json.RawMessage] + if err := json.Unmarshal(bodyBytes, &envelope); err != nil { + return nil, err + } + if envelope.Code != http.StatusOK { + return nil, &models.GatewayResponseError{StatusCode: envelope.Code, Message: strings.TrimSpace(envelope.Msg)} + } + return envelope.Data, nil +} + +func (c *agentlandGatewayClient) doRaw(ctx context.Context, method, requestPath string, headers map[string]string, body io.Reader) (*http.Response, error) { + if c.baseURL == "" { + return nil, fmt.Errorf("agentland-gateway.url is required") + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+requestPath, body) + if err != nil { + return nil, err + } + for key, value := range headers { + if strings.TrimSpace(value) == "" { + continue + } + req.Header.Set(key, value) + } + return c.httpClient.Do(req) +} + +func (c *agentlandGatewayClient) korokdPath(gatewaySessionID, subPath string, query url.Values) string { + pathValue := fmt.Sprintf("/api/agent-sessions/%s/endpoints/by-port/%d%s", url.PathEscape(strings.TrimSpace(gatewaySessionID)), agentlandKorokdPort, ensurePrefixedPath(subPath)) + if len(query) == 0 { + return pathValue + } + return pathValue + "?" + query.Encode() +} + +func ensurePrefixedPath(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "/" + } + if strings.HasPrefix(trimmed, "/") { + return trimmed + } + return "/" + trimmed +} + +func decodeGatewayError(statusCode int, body []byte) error { + var envelope gatewayEnvelope[json.RawMessage] + if json.Unmarshal(body, &envelope) == nil { + msg := strings.TrimSpace(envelope.Msg) + if msg == "" { + msg = strings.TrimSpace(string(body)) + } + code := statusCode + if envelope.Code != 0 { + code = envelope.Code + } + return &models.GatewayResponseError{StatusCode: code, Message: msg} + } + message := strings.TrimSpace(string(body)) + if message == "" { + message = http.StatusText(statusCode) + } + return &models.GatewayResponseError{StatusCode: statusCode, Message: message} +} + +func parseAgentSSE(reader io.Reader, onEvent func(*models.AgentSSEEvent) error) error { + buffered := bufio.NewReader(reader) + var eventName string + dataLines := make([]string, 0, 1) + flush := func() error { + if strings.TrimSpace(eventName) == "" && len(dataLines) == 0 { + return nil + } + data := strings.TrimSpace(strings.Join(dataLines, "\n")) + if strings.TrimSpace(eventName) != "" && onEvent != nil { + raw := json.RawMessage("null") + if data != "" { + raw = json.RawMessage(data) + } + if err := onEvent(&models.AgentSSEEvent{Event: strings.TrimSpace(eventName), Data: raw}); err != nil { + return err + } + } + eventName = "" + dataLines = dataLines[:0] + return nil + } + for { + line, err := buffered.ReadString('\n') + if err != nil && err != io.EOF { + return err + } + if err == io.EOF && len(line) == 0 { + return flush() + } + trimmed := strings.TrimRight(line, "\r\n") + if trimmed == "" { + if flushErr := flush(); flushErr != nil { + return flushErr + } + } else if strings.HasPrefix(trimmed, "event:") { + eventName = strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")) + } else if strings.HasPrefix(trimmed, "data:") { + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))) + } + if err == io.EOF { + return flush() + } + } +} + +func parseExecuteSSE(reader io.Reader) (*models.GatewayExecutionResult, error) { + result := &models.GatewayExecutionResult{} + buffered := bufio.NewReader(reader) + dataLines := make([]string, 0, 1) + flush := func() error { + if len(dataLines) == 0 { + return nil + } + payload := strings.TrimSpace(strings.Join(dataLines, "\n")) + dataLines = dataLines[:0] + if payload == "" { + return nil + } + var evt commonmodels.ExecuteStreamEvent + if err := json.Unmarshal([]byte(payload), &evt); err != nil { + return err + } + if strings.TrimSpace(evt.ContextID) != "" { + result.ContextID = strings.TrimSpace(evt.ContextID) + } + if strings.TrimSpace(evt.ExecutionID) != "" { + result.ExecutionID = strings.TrimSpace(evt.ExecutionID) + } + switch strings.TrimSpace(evt.Type) { + case "stdout": + result.Stdout += evt.Text + case "stderr": + result.Stderr += evt.Text + case "count": + result.ExecutionCount = evt.ExecutionCount + case "execution_complete": + result.ExitCode = evt.ExitCode + result.DurationMs = evt.ExecutionTime + case "error": + message := strings.TrimSpace(evt.Error) + if message == "" { + message = "code execution failed" + } + return &models.GatewayResponseError{StatusCode: http.StatusInternalServerError, Message: message} + } + return nil + } + for { + line, err := buffered.ReadString('\n') + if err != nil && err != io.EOF { + return nil, err + } + if err == io.EOF && len(line) == 0 { + break + } + trimmed := strings.TrimRight(line, "\r\n") + if trimmed == "" { + if flushErr := flush(); flushErr != nil { + return nil, flushErr + } + } else if strings.HasPrefix(trimmed, "data:") { + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))) + } + if err == io.EOF { + break + } + } + if err := flush(); err != nil { + return nil, err + } + return result, nil +} diff --git a/app/be/internal/data/agent_gateway_test.go b/app/be/internal/data/agent_gateway_test.go new file mode 100644 index 0000000..b5a5f30 --- /dev/null +++ b/app/be/internal/data/agent_gateway_test.go @@ -0,0 +1,153 @@ +package data + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/stretchr/testify/require" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestAgentlandGatewayClientEnsureSessionReadyAndWorkspaceOps(t *testing.T) { + client := &agentlandGatewayClient{ + baseURL: "http://agentland-gateway.local", + httpClient: &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + switch { + case r.URL.Path == "/api/agent-sessions/invocations/health": + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"status":"ok"}`))} + resp.Header.Set(agentlandSessionHeader, "session_123") + resp.Header.Set("Content-Type", "application/json") + return resp, nil + case r.URL.Path == "/api/agent-sessions/session_123/endpoints/by-port/8000/v1/chat/stream": + var req models.AgentChatStreamReq + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + require.Equal(t, "Build a dashboard", req.Message) + require.True(t, req.Deep) + body := bytes.NewBuffer(nil) + _, _ = fmt.Fprint(body, "event: route\n") + _, _ = fmt.Fprint(body, "data: {\"intent\":\"task\"}\n\n") + _, _ = fmt.Fprint(body, "event: session\n") + _, _ = fmt.Fprint(body, "data: {\"session_id\":\"task_123\",\"workspace_path\":\"/workspace\"}\n\n") + _, _ = fmt.Fprint(body, "event: done\n") + _, _ = fmt.Fprint(body, "data: {\"status\":\"complete\"}\n\n") + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(body)} + resp.Header.Set("Content-Type", "text/event-stream") + return resp, nil + case r.URL.Path == "/api/previews": + require.Equal(t, "session_123", r.Header.Get(agentlandSessionHeader)) + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"msg":"ok","code":200,"data":{"session_id":"session_123","port":3000,"preview_token":"pv_123","preview_url":"http://gateway/p/pv_123/","expires_at":"2026-03-13T12:30:00Z"}}`))} + resp.Header.Set("Content-Type", "application/json") + return resp, nil + case r.URL.Path == "/api/code-runner/fs/tree": + require.Equal(t, "session_123", r.Header.Get(agentlandSessionHeader)) + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"msg":"ok","code":200,"data":{"root":"/workspace","nodes":[{"path":"src","name":"src","type":"dir"},{"path":"src/main.tsx","name":"main.tsx","type":"file","size":12}]}}`))} + resp.Header.Set("Content-Type", "application/json") + return resp, nil + case r.URL.Path == "/api/code-runner/fs/file": + require.Equal(t, "session_123", r.Header.Get(agentlandSessionHeader)) + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"msg":"ok","code":200,"data":{"path":"/workspace/src/main.tsx","size":12,"encoding":"utf8","content":"hello world"}}`))} + resp.Header.Set("Content-Type", "application/json") + return resp, nil + case r.URL.Path == "/api/code-runner/contexts": + require.Equal(t, "session_123", r.Header.Get(agentlandSessionHeader)) + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"msg":"ok","code":200,"data":{"context_id":"ctx_123","language":"bash","cwd":"/workspace","state":"ready","created_at":"2026-03-13T12:00:00Z"}}`))} + resp.Header.Set("Content-Type", "application/json") + return resp, nil + case r.URL.Path == "/api/code-runner/contexts/ctx_123/execute": + require.Equal(t, "session_123", r.Header.Get(agentlandSessionHeader)) + body := bytes.NewBuffer(nil) + _, _ = fmt.Fprint(body, "data: {\"type\":\"init\",\"context_id\":\"ctx_123\",\"execution_id\":\"exec_123\"}\n\n") + _, _ = fmt.Fprint(body, "data: {\"type\":\"execution_complete\",\"context_id\":\"ctx_123\",\"execution_id\":\"exec_123\",\"exit_code\":0,\"execution_time\":10}\n\n") + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(body)} + resp.Header.Set("Content-Type", "text/event-stream") + return resp, nil + case r.URL.Path == "/api/agent-sessions/session_123/endpoints/by-port/3000/": + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("ok"))} + return resp, nil + default: + return &http.Response{StatusCode: http.StatusNotFound, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("not found"))}, nil + } + }), + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + session, err := client.EnsureSessionReady(ctx) + require.NoError(t, err) + require.Equal(t, "session_123", session.GatewaySessionID) + + events := make([]string, 0) + err = client.StreamChat(ctx, session.GatewaySessionID, &models.AgentChatStreamReq{Message: "Build a dashboard", Deep: true}, func(event *models.AgentSSEEvent) error { + events = append(events, event.Event) + return nil + }) + require.NoError(t, err) + require.Equal(t, []string{"route", "session", "done"}, events) + + previewInfo, err := client.CreatePreview(ctx, session.GatewaySessionID, 3000) + require.NoError(t, err) + require.Equal(t, "pv_123", previewInfo.PreviewToken) + require.Equal(t, "/p/pv_123/", previewInfo.PreviewURL) + + tree, err := client.GetFSTree(ctx, session.GatewaySessionID, "/workspace", 3) + require.NoError(t, err) + require.Equal(t, "/workspace", tree.Root) + require.Len(t, tree.Nodes, 2) + + file, err := client.GetFSFile(ctx, session.GatewaySessionID, "/workspace/src/main.tsx", "utf8") + require.NoError(t, err) + require.Equal(t, "hello world", file.Content) + + ctxInfo, err := client.CreateExecContext(ctx, session.GatewaySessionID, "bash", "/workspace") + require.NoError(t, err) + require.Equal(t, "ctx_123", ctxInfo.ContextID) + + execResult, err := client.ExecuteInContext(ctx, session.GatewaySessionID, ctxInfo.ContextID, "echo ok", 1000) + require.NoError(t, err) + require.Equal(t, int32(0), execResult.ExitCode) + + statusCode, err := client.ProbePort(ctx, session.GatewaySessionID, 3000, "/") + require.NoError(t, err) + require.Equal(t, http.StatusOK, statusCode) +} + +func TestAgentlandGatewayClientStreamClientDisablesTimeout(t *testing.T) { + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(""))}, nil + }) + + client := &agentlandGatewayClient{ + baseURL: "http://agentland-gateway.local", + httpClient: &http.Client{ + Timeout: 65 * time.Second, + Transport: transport, + }, + } + + streamClient := client.streamClient() + require.Zero(t, streamClient.Timeout) + require.NotNil(t, streamClient.Transport) +} + +func TestNormalizePreviewURL(t *testing.T) { + require.Equal(t, "/p/pv_123/", normalizePreviewURL("http://gateway/p/pv_123/")) + require.Equal(t, "/p/pv_123/?a=1#frag", normalizePreviewURL("http://gateway/p/pv_123/?a=1#frag")) + require.Equal(t, "/p/pv_123/", normalizePreviewURL("/p/pv_123/")) + require.Equal(t, "http://gateway/other/path", normalizePreviewURL("http://gateway/other/path")) + require.Equal(t, "", normalizePreviewURL(" ")) +} diff --git a/app/be/internal/data/auth.go b/app/be/internal/data/auth.go new file mode 100644 index 0000000..7ad9830 --- /dev/null +++ b/app/be/internal/data/auth.go @@ -0,0 +1,611 @@ +package data + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/autherr" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/token" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + "github.com/spf13/viper" +) + +const githubProvider = "github" + +var ( + sharedAuthStoreOnce sync.Once + sharedAuthStore *authStore +) + +type authStore struct { + poolOnce sync.Once + pool *pgxpool.Pool + poolErr error + redisOnce sync.Once + redis *redis.Client + redisErr error + schemaOnce sync.Once + schemaErr error + github *gitHubOAuthClient +} + +type gitHubOAuthClient struct { + httpClient *http.Client + clientID string + clientSecret string + authorizeURL string + tokenURL string + apiBaseURL string +} + +type githubAccessTokenResp struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + Description string `json:"error_description"` +} + +type githubUserAPIResp struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +type githubEmailAPIResp struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` +} + +func NewAuthRepo() biz.AuthRepo { + return sharedStore() +} + +func NewUserRepo() biz.UserRepo { + return sharedStore() +} + +func NewOAuthStateStore() biz.OAuthStateStore { + return sharedStore() +} + +func NewGitHubOAuthClient() biz.GitHubOAuthClient { + return sharedStore().github +} + +func sharedStore() *authStore { + sharedAuthStoreOnce.Do(func() { + sharedAuthStore = &authStore{ + github: &gitHubOAuthClient{ + httpClient: &http.Client{Timeout: 10 * time.Second}, + clientID: viper.GetString("auth.github.client_id"), + clientSecret: viper.GetString("auth.github.client_secret"), + authorizeURL: viper.GetString("auth.github.authorize_url"), + tokenURL: viper.GetString("auth.github.token_url"), + apiBaseURL: strings.TrimRight(viper.GetString("auth.github.api_base_url"), "/"), + }, + } + }) + return sharedAuthStore +} + +func (s *authStore) UpsertGitHubUser(ctx context.Context, profile *models.GitHubUserProfile) (*models.User, error) { + pool, err := s.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = s.ensureSchema(ctx); err != nil { + return nil, err + } + + tx, err := pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + now := time.Now().UTC() + user, err := s.findUserByProvider(ctx, tx, profile.ID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + if errors.Is(err, pgx.ErrNoRows) { + user, err = s.findOrCreateUserByEmail(ctx, tx, profile, now) + if err != nil { + return nil, err + } + } + + user, err = s.updateUserProfile(ctx, tx, user.ID, profile, now) + if err != nil { + return nil, err + } + if err = s.upsertIdentity(ctx, tx, user.ID, profile, now); err != nil { + return nil, err + } + + if err = tx.Commit(ctx); err != nil { + return nil, err + } + return user, nil +} + +func (s *authStore) GetUserByID(ctx context.Context, userID string) (*models.User, error) { + pool, err := s.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = s.ensureSchema(ctx); err != nil { + return nil, err + } + query := `select id, coalesce(email,''), name, avatar_url, plan, status, coalesce(last_login_at, now()), created_at, updated_at from users where id = $1` + user := &models.User{} + if err = pool.QueryRow(ctx, query, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.Plan, &user.Status, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, autherr.ErrUserNotFound + } + return nil, err + } + return user, nil +} + +func (s *authStore) CreateSession(ctx context.Context, input *models.CreateSessionInput) (*models.AuthSession, error) { + pool, err := s.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = s.ensureSchema(ctx); err != nil { + return nil, err + } + tx, err := pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + sessionQuery := `insert into auth_sessions (id, user_id, status, refresh_family_id, current_refresh_token_id, user_agent, ip, last_seen_at, expires_at, revoked_at, created_at) +values ($1,$2,'active',$3,$4,$5,$6,$7,$8,null,$7)` + if _, err = tx.Exec(ctx, sessionQuery, input.SessionID, input.UserID, input.RefreshFamilyID, input.RefreshTokenID, input.UserAgent, input.IP, input.Now, input.SessionExpiresAt); err != nil { + return nil, err + } + tokenQuery := `insert into auth_refresh_tokens (id, session_id, family_id, token_hash, parent_token_id, replaced_by_token_id, issued_at, expires_at, consumed_at, revoked_at) +values ($1,$2,$3,$4,null,null,$5,$6,null,null)` + if _, err = tx.Exec(ctx, tokenQuery, input.RefreshTokenID, input.SessionID, input.RefreshFamilyID, input.RefreshTokenHash, input.Now, input.RefreshExpiresAt); err != nil { + return nil, err + } + if err = tx.Commit(ctx); err != nil { + return nil, err + } + return &models.AuthSession{ + ID: input.SessionID, + UserID: input.UserID, + Status: "active", + RefreshFamilyID: input.RefreshFamilyID, + CurrentRefreshTokenID: input.RefreshTokenID, + UserAgent: input.UserAgent, + IP: input.IP, + LastSeenAt: input.Now, + ExpiresAt: input.SessionExpiresAt, + CreatedAt: input.Now, + }, nil +} + +func (s *authStore) RotateRefreshToken(ctx context.Context, input *models.RotateRefreshTokenInput) (*models.RotateRefreshTokenResult, error) { + pool, err := s.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = s.ensureSchema(ctx); err != nil { + return nil, err + } + tx, err := pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + query := `select t.id, t.session_id, t.family_id, t.expires_at, t.consumed_at, t.revoked_at, s.user_id, s.status, s.revoked_at +from auth_refresh_tokens t +join auth_sessions s on s.id = t.session_id +where t.token_hash = $1 +for update` + var tokenID, sessionID, familyID, userID, sessionStatus string + var expiresAt time.Time + var consumedAt, revokedAt, sessionRevokedAt *time.Time + if err = tx.QueryRow(ctx, query, input.CurrentTokenHash).Scan(&tokenID, &sessionID, &familyID, &expiresAt, &consumedAt, &revokedAt, &userID, &sessionStatus, &sessionRevokedAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, autherr.ErrUnauthorized + } + return nil, err + } + if revokedAt != nil || sessionRevokedAt != nil || sessionStatus != "active" { + return nil, autherr.ErrSessionRevoked + } + if input.Now.After(expiresAt) { + return nil, autherr.ErrRefreshExpired + } + if consumedAt != nil { + if err = s.revokeFamilyTx(ctx, tx, familyID, sessionID, input.Now); err != nil { + return nil, err + } + if err = tx.Commit(ctx); err != nil { + return nil, err + } + return nil, autherr.ErrRefreshReplay + } + updateOld := `update auth_refresh_tokens set consumed_at = $2, replaced_by_token_id = $3 where id = $1` + if _, err = tx.Exec(ctx, updateOld, tokenID, input.Now, input.NewTokenID); err != nil { + return nil, err + } + insertNew := `insert into auth_refresh_tokens (id, session_id, family_id, token_hash, parent_token_id, replaced_by_token_id, issued_at, expires_at, consumed_at, revoked_at) +values ($1,$2,$3,$4,$5,null,$6,$7,null,null)` + if _, err = tx.Exec(ctx, insertNew, input.NewTokenID, sessionID, familyID, input.NewTokenHash, tokenID, input.Now, input.NewExpiresAt); err != nil { + return nil, err + } + updateSession := `update auth_sessions set current_refresh_token_id = $2, last_seen_at = $3 where id = $1` + if _, err = tx.Exec(ctx, updateSession, sessionID, input.NewTokenID, input.Now); err != nil { + return nil, err + } + if err = tx.Commit(ctx); err != nil { + return nil, err + } + return &models.RotateRefreshTokenResult{UserID: userID, SessionID: sessionID, FamilyID: familyID}, nil +} + +func (s *authStore) RevokeSessionByRefreshToken(ctx context.Context, input *models.RevokeSessionByTokenInput) error { + pool, err := s.ensurePool(ctx) + if err != nil { + return err + } + if err = s.ensureSchema(ctx); err != nil { + return err + } + tx, err := pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + query := `select t.session_id, t.family_id, s.user_id from auth_refresh_tokens t join auth_sessions s on s.id = t.session_id where t.token_hash = $1 for update` + var sessionID, familyID, userID string + if err = tx.QueryRow(ctx, query, input.RefreshTokenHash).Scan(&sessionID, &familyID, &userID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return autherr.ErrUnauthorized + } + return err + } + if sessionID != input.SessionID || userID != input.UserID { + return autherr.ErrUnauthorized + } + if err = s.revokeFamilyTx(ctx, tx, familyID, sessionID, input.Now); err != nil { + return err + } + return tx.Commit(ctx) +} + +func (s *authStore) SaveGitHubState(ctx context.Context, state *models.GitHubOAuthState, ttl time.Duration) error { + client, err := s.ensureRedis() + if err != nil { + return err + } + payload, err := json.Marshal(state) + if err != nil { + return err + } + return client.Set(ctx, githubStateKey(state.State), payload, ttl).Err() +} + +func (s *authStore) ConsumeGitHubState(ctx context.Context, state string) (*models.GitHubOAuthState, error) { + client, err := s.ensureRedis() + if err != nil { + return nil, err + } + payload, err := client.GetDel(ctx, githubStateKey(state)).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, autherr.ErrOAuthStateNotFound + } + return nil, err + } + var stored models.GitHubOAuthState + if err = json.Unmarshal([]byte(payload), &stored); err != nil { + return nil, err + } + return &stored, nil +} + +func (c *gitHubOAuthClient) ExchangeCode(ctx context.Context, code, redirectURI string) (string, error) { + values := url.Values{} + values.Set("client_id", c.clientID) + values.Set("client_secret", c.clientSecret) + values.Set("code", code) + values.Set("redirect_uri", redirectURI) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(values.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 400 { + return "", autherr.ErrUnauthorized + } + var tokenResp githubAccessTokenResp + if err = json.Unmarshal(body, &tokenResp); err != nil { + return "", err + } + if strings.TrimSpace(tokenResp.AccessToken) == "" { + return "", autherr.ErrUnauthorized + } + return tokenResp.AccessToken, nil +} + +func (c *gitHubOAuthClient) FetchUser(ctx context.Context, accessToken string) (*models.GitHubUserProfile, error) { + body, err := c.doGitHubJSON(ctx, http.MethodGet, c.apiBaseURL+"/user", accessToken, nil) + if err != nil { + return nil, err + } + var apiResp githubUserAPIResp + if err = json.Unmarshal(body, &apiResp); err != nil { + return nil, err + } + return &models.GitHubUserProfile{ + ID: fmt.Sprintf("%d", apiResp.ID), + Login: apiResp.Login, + Name: fallbackName(apiResp.Name, apiResp.Login), + Email: strings.ToLower(strings.TrimSpace(apiResp.Email)), + AvatarURL: apiResp.AvatarURL, + RawProfile: append([]byte(nil), body...), + }, nil +} + +func (c *gitHubOAuthClient) FetchPrimaryVerifiedEmail(ctx context.Context, accessToken string) (string, error) { + body, err := c.doGitHubJSON(ctx, http.MethodGet, c.apiBaseURL+"/user/emails", accessToken, nil) + if err != nil { + return "", err + } + var emails []githubEmailAPIResp + if err = json.Unmarshal(body, &emails); err != nil { + return "", err + } + for _, email := range emails { + if email.Primary && email.Verified && strings.TrimSpace(email.Email) != "" { + return strings.ToLower(strings.TrimSpace(email.Email)), nil + } + } + for _, email := range emails { + if email.Verified && strings.TrimSpace(email.Email) != "" { + return strings.ToLower(strings.TrimSpace(email.Email)), nil + } + } + return "", nil +} + +func (c *gitHubOAuthClient) doGitHubJSON(ctx context.Context, method, endpoint, accessToken string, payload []byte) ([]byte, error) { + var body io.Reader + if payload != nil { + body = bytes.NewReader(payload) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, body) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, autherr.ErrUnauthorized + } + return responseBody, nil +} + +func (s *authStore) ensurePool(ctx context.Context) (*pgxpool.Pool, error) { + s.poolOnce.Do(func() { + dsn := strings.TrimSpace(viper.GetString("database.url")) + if dsn == "" { + s.poolErr = fmt.Errorf("database.url is required") + return + } + s.pool, s.poolErr = pgxpool.New(ctx, dsn) + }) + return s.pool, s.poolErr +} + +func (s *authStore) ensureRedis() (*redis.Client, error) { + s.redisOnce.Do(func() { + s.redis = redis.NewClient(&redis.Options{ + Addr: viper.GetString("redis.addr"), + Password: viper.GetString("redis.password"), + DB: viper.GetInt("redis.db"), + DialTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + ReadTimeout: 5 * time.Second, + }) + }) + return s.redis, s.redisErr +} + +func (s *authStore) ensureSchema(ctx context.Context) error { + s.schemaOnce.Do(func() { + pool, err := s.ensurePool(ctx) + if err != nil { + s.schemaErr = err + return + } + statements := []string{ + `create table if not exists users ( + id text primary key, + email text, + name text not null, + avatar_url text not null default '', + plan text not null default 'free', + status text not null default 'active', + last_login_at timestamptz, + created_at timestamptz not null, + updated_at timestamptz not null + )`, + `create unique index if not exists idx_users_email_lower on users ((lower(email))) where email is not null and email <> ''`, + `create table if not exists user_identities ( + id text primary key, + user_id text not null references users(id), + provider text not null, + provider_user_id text not null, + provider_login text not null, + provider_email text, + profile_json jsonb not null default '{}'::jsonb, + linked_at timestamptz not null, + last_login_at timestamptz not null + )`, + `create unique index if not exists idx_user_identities_provider_uid on user_identities(provider, provider_user_id)`, + `create table if not exists auth_sessions ( + id text primary key, + user_id text not null references users(id), + status text not null, + refresh_family_id text not null, + current_refresh_token_id text, + user_agent text, + ip text, + last_seen_at timestamptz not null, + expires_at timestamptz not null, + revoked_at timestamptz, + created_at timestamptz not null + )`, + `create table if not exists auth_refresh_tokens ( + id text primary key, + session_id text not null references auth_sessions(id), + family_id text not null, + token_hash text not null, + parent_token_id text, + replaced_by_token_id text, + issued_at timestamptz not null, + expires_at timestamptz not null, + consumed_at timestamptz, + revoked_at timestamptz + )`, + `create unique index if not exists idx_auth_refresh_tokens_hash on auth_refresh_tokens(token_hash)`, + `create index if not exists idx_auth_refresh_tokens_family on auth_refresh_tokens(family_id)`, + } + for _, stmt := range statements { + if _, err = pool.Exec(ctx, stmt); err != nil { + s.schemaErr = err + return + } + } + }) + return s.schemaErr +} + +func (s *authStore) findUserByProvider(ctx context.Context, tx pgx.Tx, providerUserID string) (*models.User, error) { + query := `select u.id, coalesce(u.email,''), u.name, u.avatar_url, u.plan, u.status, coalesce(u.last_login_at, now()), u.created_at, u.updated_at +from user_identities ui +join users u on u.id = ui.user_id +where ui.provider = $1 and ui.provider_user_id = $2` + user := &models.User{} + err := tx.QueryRow(ctx, query, githubProvider, providerUserID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.Plan, &user.Status, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt) + if err != nil { + return nil, err + } + return user, nil +} + +func (s *authStore) findOrCreateUserByEmail(ctx context.Context, tx pgx.Tx, profile *models.GitHubUserProfile, now time.Time) (*models.User, error) { + if strings.TrimSpace(profile.Email) != "" { + query := `select id, coalesce(email,''), name, avatar_url, plan, status, coalesce(last_login_at, now()), created_at, updated_at from users where lower(email) = lower($1)` + user := &models.User{} + err := tx.QueryRow(ctx, query, profile.Email).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.Plan, &user.Status, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt) + if err == nil { + return user, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + } + userID := token.NewID("u") + insert := `insert into users (id, email, name, avatar_url, plan, status, last_login_at, created_at, updated_at) +values ($1,$2,$3,$4,$5,'active',$6,$6,$6)` + if _, err := tx.Exec(ctx, insert, userID, nullableString(profile.Email), fallbackName(profile.Name, profile.Login), profile.AvatarURL, viper.GetString("auth.user.default_plan"), now); err != nil { + return nil, err + } + return &models.User{ID: userID, Email: profile.Email, Name: fallbackName(profile.Name, profile.Login), AvatarURL: profile.AvatarURL, Plan: viper.GetString("auth.user.default_plan"), Status: "active", LastLoginAt: now, CreatedAt: now, UpdatedAt: now}, nil +} + +func (s *authStore) updateUserProfile(ctx context.Context, tx pgx.Tx, userID string, profile *models.GitHubUserProfile, now time.Time) (*models.User, error) { + query := `update users set email = $2, name = $3, avatar_url = $4, last_login_at = $5, updated_at = $5 where id = $1 +returning id, coalesce(email,''), name, avatar_url, plan, status, coalesce(last_login_at, $5), created_at, updated_at` + user := &models.User{} + if err := tx.QueryRow(ctx, query, userID, nullableString(profile.Email), fallbackName(profile.Name, profile.Login), profile.AvatarURL, now).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.Plan, &user.Status, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt); err != nil { + return nil, err + } + return user, nil +} + +func (s *authStore) upsertIdentity(ctx context.Context, tx pgx.Tx, userID string, profile *models.GitHubUserProfile, now time.Time) error { + query := `insert into user_identities (id, user_id, provider, provider_user_id, provider_login, provider_email, profile_json, linked_at, last_login_at) +values ($1,$2,$3,$4,$5,$6,$7,$8,$8) +on conflict (provider, provider_user_id) do update set user_id = excluded.user_id, provider_login = excluded.provider_login, provider_email = excluded.provider_email, profile_json = excluded.profile_json, last_login_at = excluded.last_login_at` + _, err := tx.Exec(ctx, query, token.NewID("ident"), userID, githubProvider, profile.ID, profile.Login, nullableString(profile.Email), profile.RawProfile, now) + return err +} + +func (s *authStore) revokeFamilyTx(ctx context.Context, tx pgx.Tx, familyID, sessionID string, now time.Time) error { + if _, err := tx.Exec(ctx, `update auth_refresh_tokens set revoked_at = $2 where family_id = $1 and revoked_at is null`, familyID, now); err != nil { + return err + } + _, err := tx.Exec(ctx, `update auth_sessions set status = 'revoked', revoked_at = $2 where id = $1 and revoked_at is null`, sessionID, now) + return err +} + +func githubStateKey(state string) string { + return "auth:github:state:" + state +} + +func nullableString(value string) any { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return strings.ToLower(trimmed) +} + +func fallbackName(name, login string) string { + if strings.TrimSpace(name) != "" { + return strings.TrimSpace(name) + } + if strings.TrimSpace(login) != "" { + return strings.TrimSpace(login) + } + return "GitHub User" +} diff --git a/app/be/internal/data/data.go b/app/be/internal/data/data.go new file mode 100644 index 0000000..1d0c342 --- /dev/null +++ b/app/be/internal/data/data.go @@ -0,0 +1,3 @@ +package data + +var ProviderSet = struct{}{} diff --git a/app/be/internal/data/deployment.go b/app/be/internal/data/deployment.go new file mode 100644 index 0000000..9fc3166 --- /dev/null +++ b/app/be/internal/data/deployment.go @@ -0,0 +1,9 @@ +package data + +import "github.com/Fl0rencess720/agentland/app/be/internal/biz" + +type deploymentRepo struct{} + +func NewDeploymentRepo() biz.DeploymentRepo { + return &deploymentRepo{} +} diff --git a/app/be/internal/data/file.go b/app/be/internal/data/file.go new file mode 100644 index 0000000..41fd873 --- /dev/null +++ b/app/be/internal/data/file.go @@ -0,0 +1,9 @@ +package data + +import "github.com/Fl0rencess720/agentland/app/be/internal/biz" + +type fileRepo struct{} + +func NewFileRepo() biz.FileRepo { + return &fileRepo{} +} diff --git a/app/be/internal/data/job.go b/app/be/internal/data/job.go new file mode 100644 index 0000000..73f1fa5 --- /dev/null +++ b/app/be/internal/data/job.go @@ -0,0 +1,285 @@ +package data + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/autherr" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/spf13/viper" +) + +var ( + sharedJobRepoOnce sync.Once + sharedJobRepo *jobRepo +) + +type jobRepo struct { + poolOnce sync.Once + pool *pgxpool.Pool + poolErr error + schemaOnce sync.Once + schemaErr error +} + +func NewJobRepo() biz.JobRepo { + sharedJobRepoOnce.Do(func() { + sharedJobRepo = &jobRepo{} + }) + return sharedJobRepo +} + +func (r *jobRepo) CreateJob(ctx context.Context, input *models.CreateJobInput) (*models.Job, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + query := `insert into jobs ( + id, owner_id, project_id, type, status, progress, logs, result, request_payload, + gateway_session_id, agent_session_id, workspace_path, error_message, + created_at, updated_at, started_at, completed_at + ) values ( + $1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10,$11,$12,$13,$14,$14,null,null + ) + returning id, owner_id, project_id, type, status, progress, logs, result, request_payload, + gateway_session_id, agent_session_id, workspace_path, error_message, + created_at, updated_at, started_at, completed_at` + return r.scanJob(pool.QueryRow(ctx, query, + input.ID, + input.OwnerID, + input.ProjectID, + input.Type, + input.Status, + input.Progress, + marshalJSONValue(input.Logs, []byte("[]")), + marshalJSONValue(input.Result, []byte("null")), + marshalJSONValue(input.RequestPayload, []byte("{}")), + input.GatewaySessionID, + input.AgentSessionID, + input.WorkspacePath, + input.ErrorMessage, + input.Now, + )) +} + +func (r *jobRepo) GetJobByID(ctx context.Context, ownerID, jobID string) (*models.Job, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + query := `select id, owner_id, project_id, type, status, progress, logs, result, request_payload, + gateway_session_id, agent_session_id, workspace_path, error_message, + created_at, updated_at, started_at, completed_at + from jobs where id = $1 and owner_id = $2` + job, err := r.scanJob(pool.QueryRow(ctx, query, jobID, ownerID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, autherr.ErrJobNotFound + } + return nil, err + } + return job, nil +} + +func (r *jobRepo) GetLatestProjectRuntime(ctx context.Context, ownerID, projectID string) (*models.Job, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + query := `select id, owner_id, project_id, type, status, progress, logs, result, request_payload, + gateway_session_id, agent_session_id, workspace_path, error_message, + created_at, updated_at, started_at, completed_at + from jobs + where owner_id = $1 and project_id = $2 and type = 'APP_GENERATION' and gateway_session_id <> '' + order by + case status + when 'RUNNING' then 1 + when 'STARTING' then 2 + when 'SUCCESS' then 3 + when 'FAILED' then 4 + else 5 + end, + updated_at desc + limit 1` + job, err := r.scanJob(pool.QueryRow(ctx, query, ownerID, projectID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, autherr.ErrJobNotFound + } + return nil, err + } + return job, nil +} + +func (r *jobRepo) UpdateJob(ctx context.Context, input *models.UpdateJobInput) error { + pool, err := r.ensurePool(ctx) + if err != nil { + return err + } + if err = r.ensureSchema(ctx); err != nil { + return err + } + result, err := pool.Exec(ctx, `update jobs set + status = $2, + progress = $3, + logs = $4::jsonb, + result = $5::jsonb, + gateway_session_id = $6, + agent_session_id = $7, + workspace_path = $8, + error_message = $9, + started_at = $10, + completed_at = $11, + updated_at = $12 + where id = $1`, + input.JobID, + input.Status, + input.Progress, + marshalJSONValue(input.Logs, []byte("[]")), + marshalJSONValue(input.Result, []byte("null")), + input.GatewaySessionID, + input.AgentSessionID, + input.WorkspacePath, + input.ErrorMessage, + input.StartedAt, + input.CompletedAt, + input.UpdatedAt, + ) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return autherr.ErrJobNotFound + } + return nil +} + +func (r *jobRepo) ensurePool(ctx context.Context) (*pgxpool.Pool, error) { + r.poolOnce.Do(func() { + dsn := strings.TrimSpace(viper.GetString("database.url")) + if dsn == "" { + r.poolErr = fmt.Errorf("database.url is required") + return + } + r.pool, r.poolErr = pgxpool.New(ctx, dsn) + }) + return r.pool, r.poolErr +} + +func (r *jobRepo) ensureSchema(ctx context.Context) error { + r.schemaOnce.Do(func() { + pool, err := r.ensurePool(ctx) + if err != nil { + r.schemaErr = err + return + } + statements := []string{ + `create table if not exists jobs ( + id text primary key, + owner_id text not null references users(id), + project_id text not null references projects(id), + type text not null, + status text not null, + progress integer not null default 0, + logs jsonb not null default '[]'::jsonb, + result jsonb not null default 'null'::jsonb, + request_payload jsonb not null default '{}'::jsonb, + gateway_session_id text not null default '', + agent_session_id text not null default '', + workspace_path text not null default '', + error_message text not null default '', + created_at timestamptz not null, + updated_at timestamptz not null, + started_at timestamptz, + completed_at timestamptz + )`, + `create index if not exists idx_jobs_owner_updated on jobs (owner_id, updated_at desc)`, + `create index if not exists idx_jobs_project_updated on jobs (project_id, updated_at desc)`, + } + for _, stmt := range statements { + if _, err = pool.Exec(ctx, stmt); err != nil { + r.schemaErr = err + return + } + } + }) + return r.schemaErr +} + +type jobScanner interface { + Scan(dest ...any) error +} + +func (r *jobRepo) scanJob(scanner jobScanner) (*models.Job, error) { + var job models.Job + var logsBytes []byte + var resultBytes []byte + var requestPayloadBytes []byte + if err := scanner.Scan( + &job.ID, + &job.OwnerID, + &job.ProjectID, + &job.Type, + &job.Status, + &job.Progress, + &logsBytes, + &resultBytes, + &requestPayloadBytes, + &job.GatewaySessionID, + &job.AgentSessionID, + &job.WorkspacePath, + &job.ErrorMessage, + &job.CreatedAt, + &job.UpdatedAt, + &job.StartedAt, + &job.CompletedAt, + ); err != nil { + return nil, err + } + if len(logsBytes) > 0 { + if err := json.Unmarshal(logsBytes, &job.Logs); err != nil { + return nil, err + } + } + if len(resultBytes) > 0 && string(resultBytes) != "null" { + if err := json.Unmarshal(resultBytes, &job.Result); err != nil { + return nil, err + } + } + if len(requestPayloadBytes) > 0 && string(requestPayloadBytes) != "null" { + if err := json.Unmarshal(requestPayloadBytes, &job.RequestPayload); err != nil { + return nil, err + } + } + if job.Logs == nil { + job.Logs = []string{} + } + return &job, nil +} + +func marshalJSONValue(value any, fallback []byte) string { + if value == nil { + return string(fallback) + } + payload, err := json.Marshal(value) + if err != nil { + return string(fallback) + } + return string(payload) +} diff --git a/app/be/internal/data/project.go b/app/be/internal/data/project.go new file mode 100644 index 0000000..e5ae7f7 --- /dev/null +++ b/app/be/internal/data/project.go @@ -0,0 +1,525 @@ +package data + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/autherr" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/spf13/viper" +) + +var ( + sharedProjectRepoOnce sync.Once + sharedProjectRepo *projectRepo +) + +type projectRepo struct { + poolOnce sync.Once + pool *pgxpool.Pool + poolErr error + schemaOnce sync.Once + schemaErr error +} + +func NewProjectRepo() biz.ProjectRepo { + sharedProjectRepoOnce.Do(func() { + sharedProjectRepo = &projectRepo{} + }) + return sharedProjectRepo +} + +func (r *projectRepo) CreateProject(ctx context.Context, input *models.CreateProjectInput) (*models.Project, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + + metadata := mustMarshalProjectMetadata(models.ProjectMetadata{}) + query := `insert into projects (id, owner_id, name, template, status, thumbnail_url, metadata, last_opened_at, created_at, updated_at, deleted_at) +values ($1,$2,$3,$4,$5,'',$6,null,$7,$7,null) +returning id, owner_id, name, template, status, thumbnail_url, metadata, last_opened_at, created_at, updated_at, deleted_at` + return r.scanProject(pool.QueryRow(ctx, query, input.ID, input.OwnerID, input.Name, input.Template, input.Status, metadata, input.Now)) +} + +func (r *projectRepo) ListProjects(ctx context.Context, filter *models.ProjectListFilter) ([]*models.Project, int, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, 0, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, 0, err + } + + whereClauses := []string{"owner_id = $1", "deleted_at is null"} + args := []any{filter.OwnerID} + argIndex := 2 + if filter.Keyword != "" { + whereClauses = append(whereClauses, fmt.Sprintf("lower(name) like $%d", argIndex)) + args = append(args, "%"+strings.ToLower(filter.Keyword)+"%") + argIndex++ + } + if filter.Status != "" { + whereClauses = append(whereClauses, fmt.Sprintf("status = $%d", argIndex)) + args = append(args, filter.Status) + argIndex++ + } + whereSQL := strings.Join(whereClauses, " and ") + + countQuery := "select count(*) from projects where " + whereSQL + var total int + if err = pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, err + } + + orderBy := "updated_at desc" + if filter.View == "recent" { + orderBy = "coalesce(last_opened_at, updated_at) desc" + } else { + column := map[string]string{ + "updated_at": "updated_at", + "created_at": "created_at", + "name": "name", + }[filter.SortBy] + if column == "" { + column = "updated_at" + } + orderBy = column + " " + strings.ToUpper(filter.SortOrder) + } + limitArg := argIndex + offsetArg := argIndex + 1 + args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize) + listQuery := fmt.Sprintf(`select id, owner_id, name, template, status, thumbnail_url, metadata, last_opened_at, created_at, updated_at, deleted_at +from projects +where %s +order by %s +limit $%d offset $%d`, whereSQL, orderBy, limitArg, offsetArg) + + rows, err := pool.Query(ctx, listQuery, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + projects := make([]*models.Project, 0) + for rows.Next() { + project, scanErr := r.scanProject(rows) + if scanErr != nil { + return nil, 0, scanErr + } + projects = append(projects, project) + } + if err = rows.Err(); err != nil { + return nil, 0, err + } + return projects, total, nil +} + +func (r *projectRepo) GetProjectByID(ctx context.Context, ownerID, projectID string) (*models.Project, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + query := `select id, owner_id, name, template, status, thumbnail_url, metadata, last_opened_at, created_at, updated_at, deleted_at +from projects +where id = $1 and owner_id = $2 and deleted_at is null` + project, err := r.scanProject(pool.QueryRow(ctx, query, projectID, ownerID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, autherr.ErrProjectNotFound + } + return nil, err + } + return project, nil +} + +func (r *projectRepo) GetProjectAndTouch(ctx context.Context, ownerID, projectID string, now time.Time) (*models.Project, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + query := `update projects +set last_opened_at = $3 +where id = $1 and owner_id = $2 and deleted_at is null +returning id, owner_id, name, template, status, thumbnail_url, metadata, last_opened_at, created_at, updated_at, deleted_at` + project, err := r.scanProject(pool.QueryRow(ctx, query, projectID, ownerID, now)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, autherr.ErrProjectNotFound + } + return nil, err + } + return project, nil +} + +func (r *projectRepo) UpdateProject(ctx context.Context, input *models.UpdateProjectInput) (*models.Project, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + metadata := mustMarshalProjectMetadata(input.Metadata) + query := `update projects +set name = $3, metadata = $4, updated_at = $5 +where id = $1 and owner_id = $2 and deleted_at is null +returning id, owner_id, name, template, status, thumbnail_url, metadata, last_opened_at, created_at, updated_at, deleted_at` + project, err := r.scanProject(pool.QueryRow(ctx, query, input.ProjectID, input.OwnerID, input.Name, metadata, input.Now)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, autherr.ErrProjectNotFound + } + return nil, err + } + return project, nil +} + +func (r *projectRepo) UpdateProjectStatus(ctx context.Context, ownerID, projectID, status string, now time.Time) error { + pool, err := r.ensurePool(ctx) + if err != nil { + return err + } + if err = r.ensureSchema(ctx); err != nil { + return err + } + result, err := pool.Exec(ctx, `update projects set status = $3, updated_at = $4 where id = $1 and owner_id = $2 and deleted_at is null`, projectID, ownerID, status, now) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return autherr.ErrProjectNotFound + } + return nil +} + +func (r *projectRepo) SoftDeleteProject(ctx context.Context, ownerID, projectID string, now time.Time) error { + pool, err := r.ensurePool(ctx) + if err != nil { + return err + } + if err = r.ensureSchema(ctx); err != nil { + return err + } + result, err := pool.Exec(ctx, `update projects set deleted_at = $3, updated_at = $3 where id = $1 and owner_id = $2 and deleted_at is null`, projectID, ownerID, now) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return autherr.ErrProjectNotFound + } + return nil +} + +func (r *projectRepo) CountActiveProjectsByOwner(ctx context.Context, ownerID string) (int, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return 0, err + } + if err = r.ensureSchema(ctx); err != nil { + return 0, err + } + var total int + if err = pool.QueryRow(ctx, `select count(*) from projects where owner_id = $1 and deleted_at is null`, ownerID).Scan(&total); err != nil { + return 0, err + } + return total, nil +} + +func (r *projectRepo) GetUserPlan(ctx context.Context, userID string) (string, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return "", err + } + var plan string + if err = pool.QueryRow(ctx, `select plan from users where id = $1`, userID).Scan(&plan); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", autherr.ErrUserNotFound + } + return "", err + } + return plan, nil +} + +func (r *projectRepo) GetProjectChatSession(ctx context.Context, ownerID, projectID string) (*models.ProjectChatSession, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + query := `select project_id, owner_id, gateway_session_id, agent_chat_session_id, workspace_path, created_at, updated_at, last_message_at +from project_chat_sessions +where project_id = $1 and owner_id = $2` + session, err := r.scanProjectChatSession(pool.QueryRow(ctx, query, projectID, ownerID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return session, nil +} + +func (r *projectRepo) UpsertProjectChatSession(ctx context.Context, input *models.UpsertProjectChatSessionInput) (*models.ProjectChatSession, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + query := `insert into project_chat_sessions (project_id, owner_id, gateway_session_id, agent_chat_session_id, workspace_path, created_at, updated_at, last_message_at) +values ($1,$2,$3,$4,$5,$6,$6,$6) +on conflict (project_id) do update set + owner_id = excluded.owner_id, + gateway_session_id = excluded.gateway_session_id, + agent_chat_session_id = excluded.agent_chat_session_id, + workspace_path = excluded.workspace_path, + updated_at = excluded.updated_at, + last_message_at = excluded.last_message_at +returning project_id, owner_id, gateway_session_id, agent_chat_session_id, workspace_path, created_at, updated_at, last_message_at` + return r.scanProjectChatSession(pool.QueryRow(ctx, query, input.ProjectID, input.OwnerID, input.GatewaySessionID, input.AgentChatSessionID, input.WorkspacePath, input.Now)) +} + +func (r *projectRepo) ListProjectChatMessages(ctx context.Context, ownerID, projectID, cursor string, limit int) ([]*models.ProjectChatMessage, *string, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, nil, err + } + if limit <= 0 { + limit = 200 + } + query := `select id, project_id, owner_id, role, content, created_at +from project_chat_messages +where project_id = $1 and owner_id = $2 +order by + created_at asc, + case role + when 'user' then 1 + when 'assistant' then 2 + else 3 + end asc, + id asc +limit $3` + rows, err := pool.Query(ctx, query, projectID, ownerID, limit) + if err != nil { + return nil, nil, err + } + defer rows.Close() + messages := make([]*models.ProjectChatMessage, 0) + for rows.Next() { + message, scanErr := r.scanProjectChatMessage(rows) + if scanErr != nil { + return nil, nil, scanErr + } + messages = append(messages, message) + } + if err = rows.Err(); err != nil { + return nil, nil, err + } + _ = cursor + return messages, nil, nil +} + +func (r *projectRepo) UpdateProjectChatMessageContent(ctx context.Context, ownerID, projectID, messageID, content string) error { + pool, err := r.ensurePool(ctx) + if err != nil { + return err + } + if err = r.ensureSchema(ctx); err != nil { + return err + } + result, err := pool.Exec(ctx, `update project_chat_messages set content = $4 where id = $1 and project_id = $2 and owner_id = $3`, messageID, projectID, ownerID, content) + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return autherr.ErrProjectNotFound + } + return nil +} + +func (r *projectRepo) CreateProjectChatMessage(ctx context.Context, input *models.CreateProjectChatMessageInput) (*models.ProjectChatMessage, error) { + pool, err := r.ensurePool(ctx) + if err != nil { + return nil, err + } + if err = r.ensureSchema(ctx); err != nil { + return nil, err + } + // pgx QueryRow cannot safely consume multi-statements here, so use a transaction. + tx, err := pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + message, err := r.scanProjectChatMessage(tx.QueryRow(ctx, `insert into project_chat_messages (id, project_id, owner_id, role, content, created_at) +values ($1,$2,$3,$4,$5,$6) +returning id, project_id, owner_id, role, content, created_at`, input.ID, input.ProjectID, input.OwnerID, input.Role, input.Content, input.Now)) + if err != nil { + return nil, err + } + if _, err = tx.Exec(ctx, `update project_chat_sessions set updated_at = $3, last_message_at = $3 where project_id = $1 and owner_id = $2`, input.ProjectID, input.OwnerID, input.Now); err != nil { + return nil, err + } + if err = tx.Commit(ctx); err != nil { + return nil, err + } + return message, nil +} + +func (r *projectRepo) ensurePool(ctx context.Context) (*pgxpool.Pool, error) { + r.poolOnce.Do(func() { + dsn := strings.TrimSpace(viper.GetString("database.url")) + if dsn == "" { + r.poolErr = fmt.Errorf("database.url is required") + return + } + r.pool, r.poolErr = pgxpool.New(ctx, dsn) + }) + return r.pool, r.poolErr +} + +func (r *projectRepo) ensureSchema(ctx context.Context) error { + r.schemaOnce.Do(func() { + pool, err := r.ensurePool(ctx) + if err != nil { + r.schemaErr = err + return + } + statements := []string{ + `create table if not exists projects ( + id text primary key, + owner_id text not null references users(id), + name text not null, + template text not null, + status text not null, + thumbnail_url text not null default '', + metadata jsonb not null default '{}'::jsonb, + last_opened_at timestamptz, + created_at timestamptz not null, + updated_at timestamptz not null, + deleted_at timestamptz + )`, + `create index if not exists idx_projects_owner_deleted_updated on projects (owner_id, deleted_at, updated_at desc)`, + `create index if not exists idx_projects_owner_deleted_last_opened on projects (owner_id, deleted_at, last_opened_at desc)`, + `create index if not exists idx_projects_owner_deleted_status on projects (owner_id, deleted_at, status)`, + `create index if not exists idx_projects_owner_name_search on projects (owner_id, lower(name)) where deleted_at is null`, + `create table if not exists project_chat_sessions ( + project_id text primary key references projects(id), + owner_id text not null references users(id), + gateway_session_id text not null, + agent_chat_session_id text not null, + workspace_path text not null, + created_at timestamptz not null, + updated_at timestamptz not null, + last_message_at timestamptz not null + )`, + `create index if not exists idx_project_chat_sessions_owner_updated on project_chat_sessions (owner_id, updated_at desc)`, + `create table if not exists project_chat_messages ( + id text primary key, + project_id text not null references projects(id), + owner_id text not null references users(id), + role text not null, + content text not null, + created_at timestamptz not null + )`, + `create index if not exists idx_project_chat_messages_project_created on project_chat_messages (project_id, created_at asc)`, + } + for _, stmt := range statements { + if _, err = pool.Exec(ctx, stmt); err != nil { + r.schemaErr = err + return + } + } + }) + return r.schemaErr +} + +type projectScanner interface { + Scan(dest ...any) error +} + +func (r *projectRepo) scanProject(scanner projectScanner) (*models.Project, error) { + var project models.Project + var metadataBytes []byte + var lastOpenedAt *time.Time + var deletedAt *time.Time + if err := scanner.Scan( + &project.ID, + &project.OwnerID, + &project.Name, + &project.Template, + &project.Status, + &project.ThumbnailURL, + &metadataBytes, + &lastOpenedAt, + &project.CreatedAt, + &project.UpdatedAt, + &deletedAt, + ); err != nil { + return nil, err + } + project.LastOpenedAt = lastOpenedAt + project.DeletedAt = deletedAt + if len(metadataBytes) > 0 { + if err := json.Unmarshal(metadataBytes, &project.Metadata); err != nil { + return nil, err + } + } + return &project, nil +} + +func mustMarshalProjectMetadata(metadata models.ProjectMetadata) []byte { + payload, err := json.Marshal(metadata) + if err != nil { + return []byte("{}") + } + return payload +} + +type projectChatSessionScanner interface { + Scan(dest ...any) error +} + +func (r *projectRepo) scanProjectChatSession(scanner projectChatSessionScanner) (*models.ProjectChatSession, error) { + var session models.ProjectChatSession + if err := scanner.Scan(&session.ProjectID, &session.OwnerID, &session.GatewaySessionID, &session.AgentChatSessionID, &session.WorkspacePath, &session.CreatedAt, &session.UpdatedAt, &session.LastMessageAt); err != nil { + return nil, err + } + return &session, nil +} + +type projectChatMessageScanner interface { + Scan(dest ...any) error +} + +func (r *projectRepo) scanProjectChatMessage(scanner projectChatMessageScanner) (*models.ProjectChatMessage, error) { + var message models.ProjectChatMessage + if err := scanner.Scan(&message.ID, &message.ProjectID, &message.OwnerID, &message.Role, &message.Content, &message.CreatedAt); err != nil { + return nil, err + } + return &message, nil +} diff --git a/app/be/internal/models/auth.go b/app/be/internal/models/auth.go new file mode 100644 index 0000000..a4719f0 --- /dev/null +++ b/app/be/internal/models/auth.go @@ -0,0 +1,168 @@ +package models + +import ( + "encoding/json" + "time" +) + +type UserProfile struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + GitHubID string `json:"github_id,omitempty"` + GitHubLogin string `json:"github_login,omitempty"` + Plan string `json:"plan,omitempty"` +} + +type GitHubStartReq struct { + RedirectURI string `json:"redirect_uri" binding:"required"` +} + +type GitHubStartResp struct { + AuthorizeURL string `json:"authorize_url"` + State string `json:"state"` +} + +type GitHubCallbackReq struct { + Code string `json:"code" binding:"required"` + State string `json:"state" binding:"required"` +} + +type GitHubCallbackResp struct { + User UserProfile `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +type RefreshTokenReq struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type RefreshTokenResp struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +type CurrentUserResp struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + Plan string `json:"plan"` +} + +type LogoutReq struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +type LogoutResp struct { + Success bool `json:"success"` +} + +type AuthPrincipal struct { + UserID string + SessionID string +} + +type User struct { + ID string + Email string + Name string + AvatarURL string + Plan string + Status string + LastLoginAt time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type UserIdentity struct { + ID string + UserID string + Provider string + ProviderUserID string + ProviderLogin string + ProviderEmail string + ProfileJSON json.RawMessage + LinkedAt time.Time + LastLoginAt time.Time +} + +type AuthSession struct { + ID string + UserID string + Status string + RefreshFamilyID string + CurrentRefreshTokenID string + UserAgent string + IP string + LastSeenAt time.Time + ExpiresAt time.Time + RevokedAt *time.Time + CreatedAt time.Time +} + +type AuthRefreshToken struct { + ID string + SessionID string + FamilyID string + TokenHash string + ParentTokenID *string + ReplacedByTokenID *string + IssuedAt time.Time + ExpiresAt time.Time + ConsumedAt *time.Time + RevokedAt *time.Time +} + +type GitHubOAuthState struct { + State string `json:"state"` + RedirectURI string `json:"redirect_uri"` + IssuedAt time.Time `json:"issued_at"` +} + +type GitHubUserProfile struct { + ID string `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + RawProfile json.RawMessage `json:"-"` +} + +type CreateSessionInput struct { + UserID string + SessionID string + RefreshFamilyID string + RefreshTokenID string + RefreshTokenHash string + UserAgent string + IP string + Now time.Time + RefreshExpiresAt time.Time + SessionExpiresAt time.Time +} + +type RotateRefreshTokenInput struct { + CurrentTokenHash string + NewTokenID string + NewTokenHash string + Now time.Time + NewExpiresAt time.Time +} + +type RotateRefreshTokenResult struct { + UserID string + SessionID string + FamilyID string +} + +type RevokeSessionByTokenInput struct { + RefreshTokenHash string + UserID string + SessionID string + Now time.Time +} diff --git a/app/be/internal/models/deployment.go b/app/be/internal/models/deployment.go new file mode 100644 index 0000000..ddfb2ac --- /dev/null +++ b/app/be/internal/models/deployment.go @@ -0,0 +1,8 @@ +package models + +type DeploymentStatusResp struct { + DeploymentID string `json:"deployment_id"` + Status string `json:"status"` + Logs []string `json:"logs"` + LiveURL string `json:"live_url"` +} diff --git a/app/be/internal/models/file.go b/app/be/internal/models/file.go new file mode 100644 index 0000000..f886699 --- /dev/null +++ b/app/be/internal/models/file.go @@ -0,0 +1,23 @@ +package models + +import "mime/multipart" + +type FileUploadReq struct { + File *multipart.FileHeader `form:"file" binding:"required"` + Purpose string `form:"purpose" binding:"required"` +} + +type FileUploadResp struct { + FileID string `json:"file_id"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` +} + +type FileMetadataResp struct { + FileID string `json:"file_id"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + DownloadURL string `json:"download_url"` +} diff --git a/app/be/internal/models/job.go b/app/be/internal/models/job.go new file mode 100644 index 0000000..407be0c --- /dev/null +++ b/app/be/internal/models/job.go @@ -0,0 +1,91 @@ +package models + +import ( + "encoding/json" + "time" +) + +type JobStatusResp struct { + JobID string `json:"job_id"` + Type string `json:"type"` + Status string `json:"status"` + Progress int `json:"progress"` + Logs []string `json:"logs"` + Result any `json:"result"` +} + +type Job struct { + ID string + OwnerID string + ProjectID string + Type string + Status string + Progress int + Logs []string + Result any + RequestPayload any + GatewaySessionID string + AgentSessionID string + WorkspacePath string + ErrorMessage string + CreatedAt time.Time + UpdatedAt time.Time + StartedAt *time.Time + CompletedAt *time.Time +} + +type GenerationRequestPayload struct { + Prompt string `json:"prompt"` + Attachments []AttachmentRef `json:"attachments,omitempty"` + Deep bool `json:"deep,omitempty"` +} + +type CreateJobInput struct { + ID string + OwnerID string + ProjectID string + Type string + Status string + Progress int + Logs []string + Result any + RequestPayload any + GatewaySessionID string + AgentSessionID string + WorkspacePath string + ErrorMessage string + Now time.Time +} + +type UpdateJobInput struct { + JobID string + Status string + Progress int + Logs []string + Result any + GatewaySessionID string + AgentSessionID string + WorkspacePath string + ErrorMessage string + StartedAt *time.Time + CompletedAt *time.Time + UpdatedAt time.Time +} + +type AgentSessionInfo struct { + GatewaySessionID string +} + +type AgentChatStreamReq struct { + Message string `json:"message"` + Deep bool `json:"deep,omitempty"` + SessionID string `json:"session_id,omitempty"` + WorkspacePath string `json:"workspace_path,omitempty"` + ProjectName string `json:"project_name,omitempty"` + Iterations int `json:"iterations,omitempty"` +} + +type AgentSSEEvent struct { + Event string + Data json.RawMessage +} diff --git a/app/be/internal/models/project.go b/app/be/internal/models/project.go new file mode 100644 index 0000000..5c12ad4 --- /dev/null +++ b/app/be/internal/models/project.go @@ -0,0 +1,297 @@ +package models + +import "time" + +type ProjectListReq struct { + View string `form:"view"` + Keyword string `form:"keyword"` + Status string `form:"status"` + SortBy string `form:"sort_by"` + SortOrder string `form:"sort_order"` + Page int `form:"page"` + PageSize int `form:"page_size"` +} + +type ProjectMetadata struct { + LastViewMode string `json:"last_view_mode,omitempty"` +} + +type ProjectItem struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ThumbnailURL string `json:"thumbnail_url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + IsShared bool `json:"is_shared"` +} + +type Pagination struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + Total int `json:"total"` +} + +type ProjectListResp struct { + Items []ProjectItem `json:"items"` + Pagination Pagination `json:"pagination"` +} + +type ProjectCreateReq struct { + Name string `json:"name" binding:"required"` + Template string `json:"template" binding:"required"` +} + +type ProjectCreateResp struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` +} + +type ProjectDetailResp struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + OwnerID string `json:"owner_id"` + LastOpenedAt string `json:"last_opened_at"` + Metadata *ProjectMetadata `json:"metadata,omitempty"` +} + +type ProjectUpdateReq struct { + Name string `json:"name"` + Metadata *ProjectMetadata `json:"metadata"` +} + +type ProjectUpdateResp struct { + ID string `json:"id"` + Name string `json:"name"` + UpdatedAt string `json:"updated_at"` + Metadata *ProjectMetadata `json:"metadata,omitempty"` +} + +type ProjectDeleteResp struct { + Success bool `json:"success"` +} + +type ProjectUsageResp struct { + Used int `json:"used"` + Limit int `json:"limit"` +} + +type Project struct { + ID string + OwnerID string + Name string + Template string + Status string + ThumbnailURL string + Metadata ProjectMetadata + LastOpenedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time +} + +type ProjectListFilter struct { + OwnerID string + Keyword string + Status string + SortBy string + SortOrder string + Page int + PageSize int + View string +} + +type CreateProjectInput struct { + ID string + OwnerID string + Name string + Template string + Status string + Now time.Time +} + +type UpdateProjectInput struct { + ProjectID string + OwnerID string + Name string + Metadata ProjectMetadata + Now time.Time +} + +type AttachmentRef struct { + FileID string `json:"file_id"` + Name string `json:"name"` +} + +type GenerationCreateReq struct { + Prompt string `json:"prompt" binding:"required"` + Attachments []AttachmentRef `json:"attachments"` + Deep bool `json:"deep"` +} + +type GenerationCreateResp struct { + JobID string `json:"job_id"` + Status string `json:"status"` +} + +type ChatMessagesReq struct { + Cursor string `form:"cursor"` +} + +type ChatMessageItem struct { + ID string `json:"id"` + Role string `json:"role"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` +} + +type ChatMessagesResp struct { + Items []ChatMessageItem `json:"items"` + NextCursor *string `json:"next_cursor"` +} + +type ChatMessageCreateReq struct { + Content string `json:"content" binding:"required"` + Attachments []AttachmentRef `json:"attachments"` + Deep bool `json:"deep"` +} + +type ChatMessageStreamDeltaResp struct { + Text string `json:"text"` +} + +type FileChange struct { + Path string `json:"path"` + Action string `json:"action"` +} + +type ChatMessageStreamDoneResp struct { + MessageID string `json:"message_id"` + Changes []FileChange `json:"changes"` +} + +type ProjectChatSession struct { + ProjectID string + OwnerID string + GatewaySessionID string + AgentChatSessionID string + WorkspacePath string + CreatedAt time.Time + UpdatedAt time.Time + LastMessageAt time.Time +} + +type ProjectChatMessage struct { + ID string + ProjectID string + OwnerID string + Role string + Content string + CreatedAt time.Time +} + +type UpsertProjectChatSessionInput struct { + ProjectID string + OwnerID string + GatewaySessionID string + AgentChatSessionID string + WorkspacePath string + Now time.Time +} + +type CreateProjectChatMessageInput struct { + ID string + ProjectID string + OwnerID string + Role string + Content string + Now time.Time +} + +type FileTreeReq struct { + Path string `form:"path" binding:"required"` + Depth int `form:"depth"` +} + +type FileNode struct { + Path string `json:"path"` + Name string `json:"name"` + Type string `json:"type"` + Size int64 `json:"size,omitempty"` + Children []FileNode `json:"children,omitempty"` +} + +type FileTreeResp struct { + Root string `json:"root"` + Nodes []FileNode `json:"nodes"` +} + +type FileContentReq struct { + Path string `form:"path" binding:"required"` +} + +type FileContentResp struct { + Path string `json:"path"` + Language string `json:"language"` + Content string `json:"content"` + SHA string `json:"sha"` +} + +type PreviewStartReq struct { + Device string `json:"device"` + Port int `json:"port"` +} + +type PreviewStartResp struct { + PreviewID string `json:"preview_id"` + Status string `json:"status"` + PreviewURL string `json:"preview_url"` +} + +type PreviewStatusResp struct { + PreviewID string `json:"preview_id"` + Status string `json:"status"` + PreviewURL string `json:"preview_url"` + LastHeartbeatAt string `json:"last_heartbeat_at"` +} + +type PublishReq struct { + Channel string `json:"channel"` + VersionNote string `json:"version_note"` +} + +type PublishResp struct { + ReleaseID string `json:"release_id"` + PublicURL string `json:"public_url"` + Version string `json:"version"` +} + +type DeploymentCreateReq struct { + Environment string `json:"environment"` + BuildCommand string `json:"build_command"` + OutputDir string `json:"output_dir"` + Env map[string]string `json:"env"` +} + +type DeploymentCreateResp struct { + DeploymentID string `json:"deployment_id"` + Status string `json:"status"` +} + +type ShareCreateReq struct { + Scope string `json:"scope"` + ExpiresAt string `json:"expires_at"` + Password string `json:"password"` +} + +type ShareCreateResp struct { + ShareID string `json:"share_id"` + ShareURL string `json:"share_url"` +} + +type ShareDeleteResp struct { + Success bool `json:"success"` +} diff --git a/app/be/internal/models/workspace.go b/app/be/internal/models/workspace.go new file mode 100644 index 0000000..8392f5e --- /dev/null +++ b/app/be/internal/models/workspace.go @@ -0,0 +1,73 @@ +package models + +import ( + "fmt" + "time" +) + +type GatewayResponseError struct { + StatusCode int + Message string +} + +func (e *GatewayResponseError) Error() string { + if e == nil { + return "" + } + if e.Message == "" { + return fmt.Sprintf("gateway request failed with status %d", e.StatusCode) + } + return e.Message +} + +type GatewayPreviewInfo struct { + SessionID string + Port int + PreviewToken string + PreviewURL string + ExpiresAt time.Time +} + +type GatewayFSTreeNode struct { + Path string + Name string + Type string + Size int64 + ModTime string +} + +type GatewayFSTreeResp struct { + Root string + Nodes []GatewayFSTreeNode +} + +type GatewayFSFileResp struct { + Path string + Size int64 + Encoding string + Content string +} + +type GatewayExecContextInfo struct { + ContextID string + Language string + CWD string + State string + CreatedAt string +} + +type GatewayExecutionResult struct { + ContextID string + ExecutionID string + ExecutionCount int64 + ExitCode int32 + Stdout string + Stderr string + DurationMs int64 +} + +type WorkspaceArchive struct { + FileName string + ContentType string + Content []byte +} diff --git a/app/be/internal/pkgs/autherr/errors.go b/app/be/internal/pkgs/autherr/errors.go new file mode 100644 index 0000000..1491f5f --- /dev/null +++ b/app/be/internal/pkgs/autherr/errors.go @@ -0,0 +1,16 @@ +package autherr + +import "errors" + +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrRedirectURINotAllow = errors.New("redirect uri not allowed") + ErrOAuthStateNotFound = errors.New("oauth state not found") + ErrUserNotFound = errors.New("user not found") + ErrProjectNotFound = errors.New("project not found") + ErrProjectRuntimeUnavailable = errors.New("project runtime unavailable") + ErrJobNotFound = errors.New("job not found") + ErrRefreshReplay = errors.New("refresh token replay detected") + ErrRefreshExpired = errors.New("refresh token expired") + ErrSessionRevoked = errors.New("session revoked") +) diff --git a/app/be/internal/pkgs/jwtc/jwt.go b/app/be/internal/pkgs/jwtc/jwt.go new file mode 100644 index 0000000..b9ace37 --- /dev/null +++ b/app/be/internal/pkgs/jwtc/jwt.go @@ -0,0 +1,173 @@ +package jwtc + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Config struct { + PrivateKeyPath string + PublicKeyPath string + Issuer string + Audience string + TTL time.Duration +} + +type Claims struct { + SessionID string `json:"sid"` + jwt.RegisteredClaims +} + +type Manager struct { + privateKey *rsa.PrivateKey + publicKey *rsa.PublicKey + issuer string + audience string + ttl time.Duration +} + +func NewManager(cfg Config) (*Manager, error) { + if strings.TrimSpace(cfg.PrivateKeyPath) == "" { + return nil, fmt.Errorf("private key path is required") + } + if strings.TrimSpace(cfg.Issuer) == "" { + return nil, fmt.Errorf("issuer is required") + } + if strings.TrimSpace(cfg.Audience) == "" { + return nil, fmt.Errorf("audience is required") + } + if cfg.TTL <= 0 { + return nil, fmt.Errorf("ttl must be greater than 0") + } + + privateKey, err := loadRSAPrivateKey(cfg.PrivateKeyPath) + if err != nil { + return nil, err + } + + publicKey := &privateKey.PublicKey + if strings.TrimSpace(cfg.PublicKeyPath) != "" { + loadedPublicKey, publicErr := loadRSAPublicKey(cfg.PublicKeyPath) + if publicErr != nil { + return nil, publicErr + } + publicKey = loadedPublicKey + } else { + candidatePublicKey := filepath.Join(filepath.Dir(cfg.PrivateKeyPath), "public.pem") + if _, statErr := os.Stat(candidatePublicKey); statErr == nil { + loadedPublicKey, publicErr := loadRSAPublicKey(candidatePublicKey) + if publicErr != nil { + return nil, publicErr + } + publicKey = loadedPublicKey + } + } + + return &Manager{ + privateKey: privateKey, + publicKey: publicKey, + issuer: cfg.Issuer, + audience: cfg.Audience, + ttl: cfg.TTL, + }, nil +} + +func (m *Manager) SignAccessToken(userID, sessionID string, now time.Time) (string, time.Time, error) { + if strings.TrimSpace(userID) == "" { + return "", time.Time{}, fmt.Errorf("user id is required") + } + if strings.TrimSpace(sessionID) == "" { + return "", time.Time{}, fmt.Errorf("session id is required") + } + issuedAt := now.UTC() + expiresAt := issuedAt.Add(m.ttl) + claims := Claims{ + SessionID: sessionID, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: m.issuer, + Subject: userID, + Audience: []string{m.audience}, + IssuedAt: jwt.NewNumericDate(issuedAt), + NotBefore: jwt.NewNumericDate(issuedAt), + ExpiresAt: jwt.NewNumericDate(expiresAt), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedToken, err := token.SignedString(m.privateKey) + if err != nil { + return "", time.Time{}, fmt.Errorf("sign access token: %w", err) + } + return signedToken, expiresAt, nil +} + +func (m *Manager) VerifyAccessToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) { + if token.Method != jwt.SigningMethodRS256 { + return nil, fmt.Errorf("unexpected signing method") + } + return m.publicKey, nil + }, jwt.WithAudience(m.audience), jwt.WithIssuer(m.issuer)) + if err != nil { + return nil, fmt.Errorf("verify access token: %w", err) + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid access token") + } + if strings.TrimSpace(claims.SessionID) == "" || strings.TrimSpace(claims.Subject) == "" { + return nil, fmt.Errorf("invalid access token claims") + } + return claims, nil +} + +func loadRSAPrivateKey(path string) (*rsa.PrivateKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read private key file: %w", err) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("invalid private key pem") + } + if key, parseErr := x509.ParsePKCS1PrivateKey(block.Bytes); parseErr == nil { + return key, nil + } + parsed, parseErr := x509.ParsePKCS8PrivateKey(block.Bytes) + if parseErr != nil { + return nil, fmt.Errorf("parse private key: %w", parseErr) + } + privateKey, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is not RSA") + } + return privateKey, nil +} + +func loadRSAPublicKey(path string) (*rsa.PublicKey, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read public key file: %w", err) + } + block, _ := pem.Decode(data) + if block == nil { + return nil, fmt.Errorf("invalid public key pem") + } + parsed, parseErr := x509.ParsePKIXPublicKey(block.Bytes) + if parseErr != nil { + return nil, fmt.Errorf("parse public key: %w", parseErr) + } + publicKey, ok := parsed.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("public key is not RSA") + } + return publicKey, nil +} diff --git a/app/be/internal/pkgs/response/response.go b/app/be/internal/pkgs/response/response.go new file mode 100644 index 0000000..2a44599 --- /dev/null +++ b/app/be/internal/pkgs/response/response.go @@ -0,0 +1,113 @@ +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" +) + +type Body struct { + Msg string `json:"msg"` + Code int `json:"code"` + Data any `json:"data"` +} + +type ErrorDetail struct { + Field string `json:"field"` + Reason string `json:"reason"` +} + +type ErrorData struct { + Type string `json:"type"` + Details []ErrorDetail `json:"details,omitempty"` +} + +type APIError struct { + StatusCode int + Msg string + Data ErrorData +} + +func (e *APIError) Error() string { + return e.Msg +} + +func SuccessResponse(c *gin.Context, data any) { + c.JSON(http.StatusOK, Body{Msg: "ok", Code: http.StatusOK, Data: data}) +} + +func MessageResponse(c *gin.Context, msg string, data any) { + c.JSON(http.StatusOK, Body{Msg: msg, Code: http.StatusOK, Data: data}) +} + +func ErrorResponse(c *gin.Context, statusCode int, msg string, data any) { + c.JSON(statusCode, Body{Msg: msg, Code: statusCode, Data: data}) +} + +func WriteAPIError(c *gin.Context, err *APIError) { + if err == nil { + return + } + c.JSON(err.StatusCode, Body{Msg: err.Msg, Code: err.StatusCode, Data: err.Data}) +} + +func ValidationError(bindErr error) *APIError { + details := make([]ErrorDetail, 0) + if validationErrs, ok := bindErr.(validator.ValidationErrors); ok { + for _, item := range validationErrs { + details = append(details, ErrorDetail{Field: item.Field(), Reason: item.ActualTag()}) + } + } + if len(details) == 0 { + details = append(details, ErrorDetail{Field: "request", Reason: bindErr.Error()}) + } + return &APIError{ + StatusCode: http.StatusBadRequest, + Msg: "invalid_argument", + Data: ErrorData{Type: "VALIDATION_ERROR", Details: details}, + } +} + +func UnauthorizedError() *APIError { + return &APIError{ + StatusCode: http.StatusUnauthorized, + Msg: "unauthorized", + Data: ErrorData{Type: "AUTH_ERROR"}, + } +} + +func NotFoundError() *APIError { + return &APIError{ + StatusCode: http.StatusNotFound, + Msg: "not_found", + Data: ErrorData{Type: "NOT_FOUND"}, + } +} + +func RuntimeUnavailableError() *APIError { + return &APIError{ + StatusCode: http.StatusConflict, + Msg: "runtime_unavailable", + Data: ErrorData{Type: "RUNTIME_UNAVAILABLE"}, + } +} + +func InternalError() *APIError { + return &APIError{ + StatusCode: http.StatusInternalServerError, + Msg: "internal", + Data: ErrorData{Type: "INTERNAL_ERROR"}, + } +} + +func InvalidArgumentError(field, reason string) *APIError { + return &APIError{ + StatusCode: http.StatusBadRequest, + Msg: "invalid_argument", + Data: ErrorData{ + Type: "VALIDATION_ERROR", + Details: []ErrorDetail{{Field: field, Reason: reason}}, + }, + } +} diff --git a/app/be/internal/pkgs/token/token.go b/app/be/internal/pkgs/token/token.go new file mode 100644 index 0000000..847cfa1 --- /dev/null +++ b/app/be/internal/pkgs/token/token.go @@ -0,0 +1,32 @@ +package token + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + + "github.com/google/uuid" +) + +var rawBase64 = base64.RawURLEncoding + +func NewOpaque(prefix string) (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate token: %w", err) + } + return prefix + rawBase64.EncodeToString(buf), nil +} + +func Hash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func NewID(prefix string) string { + trimmed := strings.TrimSuffix(prefix, "_") + return trimmed + "_" + strings.ReplaceAll(uuid.NewString(), "-", "") +} diff --git a/app/be/internal/service/auth/handler.go b/app/be/internal/service/auth/handler.go new file mode 100644 index 0000000..bdf3509 --- /dev/null +++ b/app/be/internal/service/auth/handler.go @@ -0,0 +1,94 @@ +package auth + +import ( + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/Fl0rencess720/agentland/app/be/internal/service/middlewares" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type AuthHandler struct { + authUseCase biz.AuthUseCase +} + +func NewAuthHandler(authUseCase biz.AuthUseCase) *AuthHandler { + return &AuthHandler{authUseCase: authUseCase} +} + +func (h *AuthHandler) GitHubStart(c *gin.Context) { + req := models.GitHubStartReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Warn("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.authUseCase.GitHubStart(c.Request.Context(), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *AuthHandler) GitHubCallback(c *gin.Context) { + req := models.GitHubCallbackReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Warn("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.authUseCase.GitHubCallback(c.Request.Context(), &req, c.Request.UserAgent(), c.ClientIP()) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *AuthHandler) Refresh(c *gin.Context) { + req := models.RefreshTokenReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Warn("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.authUseCase.Refresh(c.Request.Context(), &req, c.Request.UserAgent(), c.ClientIP()) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *AuthHandler) Me(c *gin.Context) { + resp, apiErr := h.authUseCase.Me(c.Request.Context(), principalFromContext(c)) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *AuthHandler) Logout(c *gin.Context) { + req := models.LogoutReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Warn("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.authUseCase.Logout(c.Request.Context(), principalFromContext(c), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.MessageResponse(c, "logged_out", resp) +} + +func principalFromContext(c *gin.Context) models.AuthPrincipal { + return models.AuthPrincipal{ + UserID: c.GetString(string(middlewares.UserIDKey)), + SessionID: c.GetString(string(middlewares.SessionIDKey)), + } +} diff --git a/app/be/internal/service/auth/route.go b/app/be/internal/service/auth/route.go new file mode 100644 index 0000000..409d2b2 --- /dev/null +++ b/app/be/internal/service/auth/route.go @@ -0,0 +1,14 @@ +package auth + +import "github.com/gin-gonic/gin" + +func InitApi(group *gin.RouterGroup, authHandler *AuthHandler) { + group.GET("/me", authHandler.Me) + group.POST("/logout", authHandler.Logout) +} + +func InitNoneAuthApi(group *gin.RouterGroup, authHandler *AuthHandler) { + group.POST("/github/start", authHandler.GitHubStart) + group.POST("/github/callback", authHandler.GitHubCallback) + group.POST("/refresh", authHandler.Refresh) +} diff --git a/app/be/internal/service/deployment/handler.go b/app/be/internal/service/deployment/handler.go new file mode 100644 index 0000000..6374b46 --- /dev/null +++ b/app/be/internal/service/deployment/handler.go @@ -0,0 +1,23 @@ +package deployment + +import ( + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/gin-gonic/gin" +) + +type DeploymentHandler struct { + deploymentUseCase biz.DeploymentUseCase +} + +func NewDeploymentHandler(deploymentUseCase biz.DeploymentUseCase) *DeploymentHandler { + return &DeploymentHandler{deploymentUseCase: deploymentUseCase} +} + +func (h *DeploymentHandler) Detail(c *gin.Context) { + ctx := c.Request.Context() + _ = ctx + + response.SuccessResponse(c, models.DeploymentStatusResp{DeploymentID: c.Param("deployment_id")}) +} diff --git a/app/be/internal/service/deployment/route.go b/app/be/internal/service/deployment/route.go new file mode 100644 index 0000000..69be62a --- /dev/null +++ b/app/be/internal/service/deployment/route.go @@ -0,0 +1,7 @@ +package deployment + +import "github.com/gin-gonic/gin" + +func InitApi(group *gin.RouterGroup, deploymentHandler *DeploymentHandler) { + group.GET("/:deployment_id", deploymentHandler.Detail) +} diff --git a/app/be/internal/service/file/handler.go b/app/be/internal/service/file/handler.go new file mode 100644 index 0000000..aea082a --- /dev/null +++ b/app/be/internal/service/file/handler.go @@ -0,0 +1,40 @@ +package file + +import ( + "net/http" + + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type FileHandler struct { + fileUseCase biz.FileUseCase +} + +func NewFileHandler(fileUseCase biz.FileUseCase) *FileHandler { + return &FileHandler{fileUseCase: fileUseCase} +} + +func (h *FileHandler) Upload(c *gin.Context) { + ctx := c.Request.Context() + _ = ctx + + req := models.FileUploadReq{} + if err := c.ShouldBind(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.ErrorResponse(c, http.StatusBadRequest, "invalid_argument", gin.H{"type": "VALIDATION_ERROR"}) + return + } + + response.MessageResponse(c, "uploaded", models.FileUploadResp{}) +} + +func (h *FileHandler) Detail(c *gin.Context) { + ctx := c.Request.Context() + _ = ctx + + response.SuccessResponse(c, models.FileMetadataResp{FileID: c.Param("file_id")}) +} diff --git a/app/be/internal/service/file/route.go b/app/be/internal/service/file/route.go new file mode 100644 index 0000000..7f690db --- /dev/null +++ b/app/be/internal/service/file/route.go @@ -0,0 +1,8 @@ +package file + +import "github.com/gin-gonic/gin" + +func InitApi(group *gin.RouterGroup, fileHandler *FileHandler) { + group.POST("", fileHandler.Upload) + group.GET("/:file_id", fileHandler.Detail) +} diff --git a/app/be/internal/service/init.go b/app/be/internal/service/init.go new file mode 100644 index 0000000..8368be8 --- /dev/null +++ b/app/be/internal/service/init.go @@ -0,0 +1,65 @@ +package service + +import ( + "net/http" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/service/auth" + "github.com/Fl0rencess720/agentland/app/be/internal/service/deployment" + "github.com/Fl0rencess720/agentland/app/be/internal/service/file" + "github.com/Fl0rencess720/agentland/app/be/internal/service/job" + "github.com/Fl0rencess720/agentland/app/be/internal/service/middlewares" + "github.com/Fl0rencess720/agentland/app/be/internal/service/project" + ginzap "github.com/gin-contrib/zap" + "github.com/gin-gonic/gin" + "github.com/spf13/viper" + "go.uber.org/zap" +) + +var ProviderSet = struct{}{} + +type HTTPServer struct { + *http.Server +} + +func NewHTTPServer( + rateLimiter *middlewares.IPRateLimiter, + authHandler *auth.AuthHandler, + projectHandler *project.ProjectHandler, + jobHandler *job.JobHandler, + deploymentHandler *deployment.DeploymentHandler, + fileHandler *file.FileHandler, +) *HTTPServer { + e := gin.New() + e.Use( + gin.Logger(), + gin.Recovery(), + ginzap.Ginzap(zap.L(), time.RFC3339, false), + ginzap.RecoveryWithZap(zap.L(), false), + ) + + e.Use(middlewares.Cors()) + e.Use(middlewares.Trace()) + e.Use(middlewares.IPRateLimitMiddleware(rateLimiter)) + + app := e.Group("/api/v1", middlewares.Auth()) + { + auth.InitApi(app.Group("/auth"), authHandler) + project.InitApi(app.Group("/projects"), projectHandler) + job.InitApi(app.Group("/jobs"), jobHandler) + deployment.InitApi(app.Group("/deployments"), deploymentHandler) + file.InitApi(app.Group("/files"), fileHandler) + } + + appNoneAuth := e.Group("/api/v1") + { + auth.InitNoneAuthApi(appNoneAuth.Group("/auth"), authHandler) + } + + return &HTTPServer{ + Server: &http.Server{ + Addr: viper.GetString("server.http.addr"), + Handler: e, + }, + } +} diff --git a/app/be/internal/service/job/handler.go b/app/be/internal/service/job/handler.go new file mode 100644 index 0000000..7655bfd --- /dev/null +++ b/app/be/internal/service/job/handler.go @@ -0,0 +1,33 @@ +package job + +import ( + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/Fl0rencess720/agentland/app/be/internal/service/middlewares" + "github.com/gin-gonic/gin" +) + +type JobHandler struct { + jobUseCase biz.JobUseCase +} + +func NewJobHandler(jobUseCase biz.JobUseCase) *JobHandler { + return &JobHandler{jobUseCase: jobUseCase} +} + +func (h *JobHandler) Detail(c *gin.Context) { + resp, apiErr := h.jobUseCase.Detail(c.Request.Context(), principalFromContext(c), c.Param("job_id")) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func principalFromContext(c *gin.Context) models.AuthPrincipal { + return models.AuthPrincipal{ + UserID: c.GetString(string(middlewares.UserIDKey)), + SessionID: c.GetString(string(middlewares.SessionIDKey)), + } +} diff --git a/app/be/internal/service/job/route.go b/app/be/internal/service/job/route.go new file mode 100644 index 0000000..d192910 --- /dev/null +++ b/app/be/internal/service/job/route.go @@ -0,0 +1,7 @@ +package job + +import "github.com/gin-gonic/gin" + +func InitApi(group *gin.RouterGroup, jobHandler *JobHandler) { + group.GET("/:job_id", jobHandler.Detail) +} diff --git a/app/be/internal/service/middlewares/auth.go b/app/be/internal/service/middlewares/auth.go new file mode 100644 index 0000000..72ba0dd --- /dev/null +++ b/app/be/internal/service/middlewares/auth.go @@ -0,0 +1,70 @@ +package middlewares + +import ( + "net/http" + "strings" + "sync" + + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/jwtc" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/gin-gonic/gin" + "github.com/spf13/viper" +) + +type ContextKey string + +var ( + UserIDKey = ContextKey("user_id") + SessionIDKey = ContextKey("session_id") + jwtOnce sync.Once + jwtManager *jwtc.Manager + jwtErr error +) + +func Auth() gin.HandlerFunc { + return func(c *gin.Context) { + tokenString := c.GetHeader("Authorization") + if tokenString == "" { + response.WriteAPIError(c, response.UnauthorizedError()) + c.Abort() + return + } + + parts := strings.Fields(strings.TrimSpace(tokenString)) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" { + response.WriteAPIError(c, response.UnauthorizedError()) + c.Abort() + return + } + + manager, err := getJWTManager() + if err != nil { + response.ErrorResponse(c, http.StatusInternalServerError, "internal", response.ErrorData{Type: "INTERNAL_ERROR"}) + c.Abort() + return + } + claims, err := manager.VerifyAccessToken(parts[1]) + if err != nil { + response.WriteAPIError(c, response.UnauthorizedError()) + c.Abort() + return + } + + c.Set(string(UserIDKey), claims.Subject) + c.Set(string(SessionIDKey), claims.SessionID) + c.Next() + } +} + +func getJWTManager() (*jwtc.Manager, error) { + jwtOnce.Do(func() { + jwtManager, jwtErr = jwtc.NewManager(jwtc.Config{ + PrivateKeyPath: viper.GetString("auth.jwt.private_key_path"), + PublicKeyPath: viper.GetString("auth.jwt.public_key_path"), + Issuer: viper.GetString("auth.jwt.issuer"), + Audience: viper.GetString("auth.jwt.audience"), + TTL: viper.GetDuration("auth.access_ttl"), + }) + }) + return jwtManager, jwtErr +} diff --git a/app/be/internal/service/middlewares/auth_test.go b/app/be/internal/service/middlewares/auth_test.go new file mode 100644 index 0000000..f3a637c --- /dev/null +++ b/app/be/internal/service/middlewares/auth_test.go @@ -0,0 +1,48 @@ +package middlewares + +import ( + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/jwtc" + "github.com/Fl0rencess720/agentland/pkg/common/testutil" + "github.com/gin-gonic/gin" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" +) + +func TestAuthMiddleware(t *testing.T) { + gin.SetMode(gin.ReleaseMode) + privatePath, publicPath, err := testutil.WriteTestRSAKeys(t.TempDir()) + require.NoError(t, err) + viper.Set("auth.jwt.private_key_path", privatePath) + viper.Set("auth.jwt.public_key_path", publicPath) + viper.Set("auth.jwt.issuer", "agentland-app-be") + viper.Set("auth.jwt.audience", "agentland-app") + viper.Set("auth.access_ttl", 15*time.Minute) + + jwtOnce = sync.Once{} + jwtManager = nil + jwtErr = nil + + manager, err := jwtc.NewManager(jwtc.Config{PrivateKeyPath: privatePath, PublicKeyPath: publicPath, Issuer: "agentland-app-be", Audience: "agentland-app", TTL: 15 * time.Minute}) + require.NoError(t, err) + token, _, err := manager.SignAccessToken("u_123", "sess_123", time.Now()) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + ctx, router := gin.CreateTestContext(recorder) + router.Use(Auth()) + router.GET("/me", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"user_id": c.GetString(string(UserIDKey)), "session_id": c.GetString(string(SessionIDKey))}) + }) + ctx.Request = httptest.NewRequest(http.MethodGet, "/me", nil) + ctx.Request.Header.Set("Authorization", "Bearer "+token) + + router.HandleContext(ctx) + require.Equal(t, http.StatusOK, recorder.Code) + require.JSONEq(t, `{"user_id":"u_123","session_id":"sess_123"}`, recorder.Body.String()) +} diff --git a/app/be/internal/service/middlewares/cors.go b/app/be/internal/service/middlewares/cors.go new file mode 100644 index 0000000..9a322ee --- /dev/null +++ b/app/be/internal/service/middlewares/cors.go @@ -0,0 +1,20 @@ +package middlewares + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func Cors() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} diff --git a/app/be/internal/service/middlewares/middlewares.go b/app/be/internal/service/middlewares/middlewares.go new file mode 100644 index 0000000..c2ea561 --- /dev/null +++ b/app/be/internal/service/middlewares/middlewares.go @@ -0,0 +1,3 @@ +package middlewares + +var ProviderSet = struct{}{} diff --git a/app/be/internal/service/middlewares/ratelimiter.go b/app/be/internal/service/middlewares/ratelimiter.go new file mode 100644 index 0000000..6b90d25 --- /dev/null +++ b/app/be/internal/service/middlewares/ratelimiter.go @@ -0,0 +1,54 @@ +package middlewares + +import ( + "net/http" + "sync" + + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/gin-gonic/gin" + "golang.org/x/time/rate" +) + +type IPRateLimiter struct { + ips map[string]*rate.Limiter + mu *sync.RWMutex + r rate.Limit + b int +} + +func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter { + return &IPRateLimiter{ + ips: make(map[string]*rate.Limiter), + mu: &sync.RWMutex{}, + r: r, + b: b, + } +} + +func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter { + i.mu.Lock() + defer i.mu.Unlock() + + limiter, exists := i.ips[ip] + if !exists { + limiter = rate.NewLimiter(i.r, i.b) + i.ips[ip] = limiter + } + + return limiter +} + +func IPRateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc { + return func(c *gin.Context) { + if !limiter.GetLimiter(c.ClientIP()).Allow() { + response.ErrorResponse(c, http.StatusTooManyRequests, "rate_limited", gin.H{"type": "RATE_LIMIT_ERROR"}) + c.Abort() + return + } + c.Next() + } +} + +func NewDefaultIPRateLimiter() *IPRateLimiter { + return NewIPRateLimiter(10, 20) +} diff --git a/app/be/internal/service/middlewares/trace.go b/app/be/internal/service/middlewares/trace.go new file mode 100644 index 0000000..d427bc7 --- /dev/null +++ b/app/be/internal/service/middlewares/trace.go @@ -0,0 +1,15 @@ +package middlewares + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func Trace() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := uuid.NewString() + c.Set("request_id", requestID) + c.Writer.Header().Set("X-Request-ID", requestID) + c.Next() + } +} diff --git a/app/be/internal/service/project/handler.go b/app/be/internal/service/project/handler.go new file mode 100644 index 0000000..e645cfb --- /dev/null +++ b/app/be/internal/service/project/handler.go @@ -0,0 +1,288 @@ +package project + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/Fl0rencess720/agentland/app/be/internal/biz" + "github.com/Fl0rencess720/agentland/app/be/internal/models" + "github.com/Fl0rencess720/agentland/app/be/internal/pkgs/response" + "github.com/Fl0rencess720/agentland/app/be/internal/service/middlewares" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type ProjectHandler struct { + projectUseCase biz.ProjectUseCase +} + +func NewProjectHandler(projectUseCase biz.ProjectUseCase) *ProjectHandler { + return &ProjectHandler{projectUseCase: projectUseCase} +} + +func (h *ProjectHandler) List(c *gin.Context) { + req := models.ProjectListReq{} + if err := c.ShouldBindQuery(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.projectUseCase.List(c.Request.Context(), principalFromContext(c), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *ProjectHandler) Create(c *gin.Context) { + req := models.ProjectCreateReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.projectUseCase.Create(c.Request.Context(), principalFromContext(c), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.MessageResponse(c, "created", resp) +} + +func (h *ProjectHandler) Detail(c *gin.Context) { + resp, apiErr := h.projectUseCase.Detail(c.Request.Context(), principalFromContext(c), c.Param("project_id")) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *ProjectHandler) Update(c *gin.Context) { + req := models.ProjectUpdateReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.projectUseCase.Update(c.Request.Context(), principalFromContext(c), c.Param("project_id"), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.MessageResponse(c, "updated", resp) +} + +func (h *ProjectHandler) Delete(c *gin.Context) { + resp, apiErr := h.projectUseCase.Delete(c.Request.Context(), principalFromContext(c), c.Param("project_id")) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.MessageResponse(c, "deleted", resp) +} + +func (h *ProjectHandler) Usage(c *gin.Context) { + resp, apiErr := h.projectUseCase.Usage(c.Request.Context(), principalFromContext(c)) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *ProjectHandler) CreateGeneration(c *gin.Context) { + req := models.GenerationCreateReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.projectUseCase.CreateGeneration(c.Request.Context(), principalFromContext(c), c.Param("project_id"), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.MessageResponse(c, "accepted", resp) +} + +func (h *ProjectHandler) ListMessages(c *gin.Context) { + req := models.ChatMessagesReq{} + if err := c.ShouldBindQuery(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.projectUseCase.ListMessages(c.Request.Context(), principalFromContext(c), c.Param("project_id"), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *ProjectHandler) CreateMessage(c *gin.Context) { + req := models.ChatMessageCreateReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + + writeEvent := func(msg string, data any) error { + body, err := json.Marshal(response.Body{Msg: msg, Code: http.StatusOK, Data: data}) + if err != nil { + return err + } + if _, err = fmt.Fprintf(c.Writer, "data: %s\n\n", body); err != nil { + return err + } + c.Writer.Flush() + return nil + } + + resp, err := h.projectUseCase.CreateMessage(c.Request.Context(), principalFromContext(c), c.Param("project_id"), &req, func(delta string) error { + return writeEvent("delta", models.ChatMessageStreamDeltaResp{Text: delta}) + }) + if err != nil { + zap.L().Error("create project chat message failed", zap.Error(err), zap.String("project_id", c.Param("project_id"))) + if streamErr := writeEvent("error", gin.H{"message": err.Error()}); streamErr != nil { + zap.L().Error("write project chat error event failed", zap.Error(streamErr), zap.String("project_id", c.Param("project_id"))) + } + return + } + + if err := writeEvent("done", resp); err != nil { + zap.L().Error("write project chat done event failed", zap.Error(err), zap.String("project_id", c.Param("project_id"))) + } +} + +func (h *ProjectHandler) FileTree(c *gin.Context) { + req := models.FileTreeReq{} + if err := c.ShouldBindQuery(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.projectUseCase.FileTree(c.Request.Context(), principalFromContext(c), c.Param("project_id"), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *ProjectHandler) FileContent(c *gin.Context) { + req := models.FileContentReq{} + if err := c.ShouldBindQuery(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.projectUseCase.FileContent(c.Request.Context(), principalFromContext(c), c.Param("project_id"), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *ProjectHandler) Download(c *gin.Context) { + archive, apiErr := h.projectUseCase.Download(c.Request.Context(), principalFromContext(c), c.Param("project_id")) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + c.Header("Content-Type", archive.ContentType) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", archive.FileName)) + c.Data(http.StatusOK, archive.ContentType, archive.Content) +} + +func (h *ProjectHandler) StartPreview(c *gin.Context) { + req := models.PreviewStartReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.WriteAPIError(c, response.ValidationError(err)) + return + } + resp, apiErr := h.projectUseCase.StartPreview(c.Request.Context(), principalFromContext(c), c.Param("project_id"), &req) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.MessageResponse(c, "preview_started", resp) +} + +func (h *ProjectHandler) PreviewStatus(c *gin.Context) { + resp, apiErr := h.projectUseCase.PreviewStatus(c.Request.Context(), principalFromContext(c), c.Param("project_id")) + if apiErr != nil { + response.WriteAPIError(c, apiErr) + return + } + response.SuccessResponse(c, resp) +} + +func (h *ProjectHandler) Publish(c *gin.Context) { + ctx := c.Request.Context() + _ = ctx + + req := models.PublishReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.ErrorResponse(c, http.StatusBadRequest, "invalid_argument", gin.H{"type": "VALIDATION_ERROR"}) + return + } + + response.MessageResponse(c, "published", models.PublishResp{}) +} + +func (h *ProjectHandler) CreateDeployment(c *gin.Context) { + ctx := c.Request.Context() + _ = ctx + + req := models.DeploymentCreateReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.ErrorResponse(c, http.StatusBadRequest, "invalid_argument", gin.H{"type": "VALIDATION_ERROR"}) + return + } + + response.MessageResponse(c, "deployment_started", models.DeploymentCreateResp{}) +} + +func (h *ProjectHandler) CreateShare(c *gin.Context) { + ctx := c.Request.Context() + _ = ctx + + req := models.ShareCreateReq{} + if err := c.ShouldBindJSON(&req); err != nil { + zap.L().Error("request bind error", zap.Error(err)) + response.ErrorResponse(c, http.StatusBadRequest, "invalid_argument", gin.H{"type": "VALIDATION_ERROR"}) + return + } + + response.SuccessResponse(c, models.ShareCreateResp{}) +} + +func (h *ProjectHandler) DeleteShare(c *gin.Context) { + ctx := c.Request.Context() + _ = ctx + _ = c.Param("share_id") + + response.MessageResponse(c, "deleted", models.ShareDeleteResp{Success: true}) +} + +func principalFromContext(c *gin.Context) models.AuthPrincipal { + return models.AuthPrincipal{ + UserID: c.GetString(string(middlewares.UserIDKey)), + SessionID: c.GetString(string(middlewares.SessionIDKey)), + } +} diff --git a/app/be/internal/service/project/route.go b/app/be/internal/service/project/route.go new file mode 100644 index 0000000..a232fcf --- /dev/null +++ b/app/be/internal/service/project/route.go @@ -0,0 +1,24 @@ +package project + +import "github.com/gin-gonic/gin" + +func InitApi(group *gin.RouterGroup, projectHandler *ProjectHandler) { + group.GET("", projectHandler.List) + group.POST("", projectHandler.Create) + group.GET("/usage", projectHandler.Usage) + group.GET("/:project_id", projectHandler.Detail) + group.PATCH("/:project_id", projectHandler.Update) + group.DELETE("/:project_id", projectHandler.Delete) + group.POST("/:project_id/generations", projectHandler.CreateGeneration) + group.GET("/:project_id/chat/messages", projectHandler.ListMessages) + group.POST("/:project_id/chat/messages", projectHandler.CreateMessage) + group.GET("/:project_id/files/tree", projectHandler.FileTree) + group.GET("/:project_id/files/content", projectHandler.FileContent) + group.GET("/:project_id/download", projectHandler.Download) + group.POST("/:project_id/preview/start", projectHandler.StartPreview) + group.GET("/:project_id/preview", projectHandler.PreviewStatus) + group.POST("/:project_id/publish", projectHandler.Publish) + group.POST("/:project_id/deployments", projectHandler.CreateDeployment) + group.POST("/:project_id/shares", projectHandler.CreateShare) + group.DELETE("/:project_id/shares/:share_id", projectHandler.DeleteShare) +} diff --git a/app/fe/.gitignore b/app/fe/.gitignore new file mode 100644 index 0000000..e2822c7 --- /dev/null +++ b/app/fe/.gitignore @@ -0,0 +1,32 @@ +## Dependencies +node_modules/ + +## Build output +dist/ +build/ +coverage/ +.vite/ + +## Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +## Environment files +.env +.env.* +!.env.example + +## OS / Editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +## Misc cache/temp +*.tsbuildinfo +.cache/ +.temp/ +tmp/ diff --git a/app/fe/.ralphy/config.yaml b/app/fe/.ralphy/config.yaml new file mode 100644 index 0000000..1fd3e3c --- /dev/null +++ b/app/fe/.ralphy/config.yaml @@ -0,0 +1,35 @@ +# Ralphy Configuration +# https://github.com/michaelshimeles/ralphy + +# Project info (auto-detected, edit if needed) +project: + name: "agentland" + language: "TypeScript" + framework: "react" + description: "" # Add a brief description + +# Commands (auto-detected from package.json/pyproject.toml) +commands: + test: "" + lint: "npm run lint" + build: "npm run build" + +# Rules - instructions the AI MUST follow +# These are injected into every prompt +rules: + # Examples: + # - "Always use TypeScript strict mode" + # - "Follow the error handling pattern in src/utils/errors.ts" + # - "All API endpoints must have input validation with Zod" + # - "Use server actions instead of API routes in Next.js" + # + # Skills/playbooks (optional): + # - "Before coding, read and follow any relevant skill/playbook docs under .opencode/skills or .claude/skills." + +# Boundaries - files/folders the AI should not modify +boundaries: + never_touch: + # Examples: + # - "src/legacy/**" + # - "migrations/**" + # - "*.lock" diff --git a/app/fe/.ralphy/progress.txt b/app/fe/.ralphy/progress.txt new file mode 100644 index 0000000..7c396bd --- /dev/null +++ b/app/fe/.ralphy/progress.txt @@ -0,0 +1,2 @@ +# Ralphy Progress Log + diff --git a/app/fe/API.md b/app/fe/API.md new file mode 100644 index 0000000..dc53213 --- /dev/null +++ b/app/fe/API.md @@ -0,0 +1,750 @@ +# API 文档(面向当前前端原型) + +本文基于当前项目页面交互(`Login`、`Dashboard`、`Projects`、`Workspace`、 +`CodeEditor`)抽象后端接口。范围覆盖认证、项目管理、AI 生成、聊天、 +文件系统、预览、发布、部署与分享。 + +## 1. 全局约定 + +### 1.1 Base URL + +`/api/v1` + +### 1.2 鉴权 + +- 登录前接口无需鉴权。 +- 登录后接口统一携带 Header: + +```http +Authorization: Bearer +``` + +### 1.3 通用成功响应 + +```json +{ + "msg": "ok", + "code": 200, + "data": {} +} +``` + +### 1.4 通用失败响应 + +```json +{ + "msg": "invalid_argument", + "code": 400, + "data": { + "type": "VALIDATION_ERROR", + "details": [ + { + "field": "email", + "reason": "invalid format" + } + ] + } +} +``` + +## 2. 认证与用户 + +### 2.1 GitHub 登录发起 + +- URL: `POST /api/v1/auth/github/start` +- 功能说明: 登录页唯一入口,点击 **Continue with GitHub** 后调用,返回 + GitHub 授权地址。 +- 请求体: + +```json +{ + "redirect_uri": "https://app.example.com/auth/github/callback" +} +``` + +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "authorize_url": "https://github.com/login/oauth/authorize?...", + "state": "st_abc123" + } +} +``` + +### 2.2 GitHub 登录回调 + +- URL: `POST /api/v1/auth/github/callback` +- 功能说明: 前端拿到 GitHub `code/state` 后回传服务端,交换用户身份并签发 + 平台会话 token。 +- 请求体: + +```json +{ + "code": "github_auth_code", + "state": "st_abc123" +} +``` + +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "user": { + "id": "u_123", + "email": "user@company.com", + "name": "Alice", + "avatar_url": "https://avatars.githubusercontent.com/u/123?v=4", + "github_id": "1234567", + "github_login": "alice-dev" + }, + "access_token": "jwt_access", + "refresh_token": "jwt_refresh", + "expires_in": 7200 + } +} +``` + +### 2.3 刷新 token + +- URL: `POST /api/v1/auth/refresh` +- 功能说明: 前端无感续期会话。 +- 请求体: + +```json +{ + "refresh_token": "jwt_refresh" +} +``` + +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "access_token": "jwt_access_new", + "refresh_token": "jwt_refresh_new", + "expires_in": 7200 + } +} +``` + +### 2.4 当前用户信息 + +- URL: `GET /api/v1/auth/me` +- 功能说明: 页面初始化时拉取用户资料、套餐信息。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "id": "u_123", + "email": "user@company.com", + "name": "Alice", + "avatar_url": "", + "plan": "pro" + } +} +``` + +### 2.5 退出登录 + +- URL: `POST /api/v1/auth/logout` +- 功能说明: 对应头像菜单退出。 +- 请求体: + +```json +{ + "refresh_token": "jwt_refresh" +} +``` + +- 响应体: + +```json +{ + "msg": "logged_out", + "code": 200, + "data": { + "success": true + } +} +``` + +## 3. 项目管理(Projects 页面) + +### 3.1 项目列表 + +- URL: `GET /api/v1/projects?view=all&keyword=dashboard&status=deployed&sort_by=updated_at&sort_order=desc&page=1&page_size=20` +- 功能说明: 支持 **All / Recent / Shared**、搜索、过滤、排序、分页。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "items": [ + { + "id": "p_001", + "name": "SaaS Dashboard", + "status": "DEPLOYED", + "thumbnail_url": "https://cdn.example.com/p1.png", + "created_at": "2026-03-10T12:00:00Z", + "updated_at": "2026-03-11T09:00:00Z", + "is_shared": true + } + ], + "pagination": { + "page": 1, + "page_size": 20, + "total": 35 + } + } +} +``` + +### 3.2 创建项目 + +- URL: `POST /api/v1/projects` +- 功能说明: 对应 **New App / Create New App**。 +- 请求体: + +```json +{ + "name": "Untitled Project", + "template": "blank" +} +``` + +- 响应体: + +```json +{ + "msg": "created", + "code": 200, + "data": { + "id": "p_100", + "name": "Untitled Project", + "status": "DRAFT", + "created_at": "2026-03-11T09:15:00Z" + } +} +``` + +### 3.3 项目详情 + +- URL: `GET /api/v1/projects/{project_id}` +- 功能说明: 打开编辑器前拉取项目元信息。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "id": "p_100", + "name": "Untitled Project", + "status": "BUILDING", + "owner_id": "u_123", + "last_opened_at": "2026-03-11T09:16:00Z" + } +} +``` + +### 3.4 更新项目 + +- URL: `PATCH /api/v1/projects/{project_id}` +- 功能说明: 重命名项目、保存项目元数据。 +- 请求体: + +```json +{ + "name": "Marketing Analytics", + "metadata": { + "last_view_mode": "code" + } +} +``` + +- 响应体: + +```json +{ + "msg": "updated", + "code": 200, + "data": { + "id": "p_100", + "name": "Marketing Analytics", + "updated_at": "2026-03-11T09:20:00Z" + } +} +``` + +### 3.5 删除项目 + +- URL: `DELETE /api/v1/projects/{project_id}` +- 功能说明: 对应项目卡片删除按钮。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "deleted", + "code": 200, + "data": { + "success": true + } +} +``` + +### 3.6 项目配额与用量 + +- URL: `GET /api/v1/projects/usage` +- 功能说明: 对应侧栏 `8 of 12 projects used`。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "used": 8, + "limit": 12 + } +} +``` + +## 4. 应用生成(Dashboard: Generate App) + +### 4.1 发起生成任务 + +- URL: `POST /api/v1/projects/{project_id}/generations` +- 功能说明: 使用 prompt 生成初始项目代码。 +- 请求体: + +```json +{ + "prompt": "Create a SaaS dashboard with dark mode and realtime charts.", + "attachments": [ + { + "file_id": "f_001", + "name": "prd.md" + } + ] +} +``` + +- 响应体: + +```json +{ + "msg": "accepted", + "code": 200, + "data": { + "job_id": "job_gen_001", + "status": "QUEUED" + } +} +``` + +### 4.2 查询任务状态 + +- URL: `GET /api/v1/jobs/{job_id}` +- 功能说明: 轮询生成任务进度与结果。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "job_id": "job_gen_001", + "type": "APP_GENERATION", + "status": "RUNNING", + "progress": 68, + "logs": [ + "Scaffolding project", + "Generating components" + ], + "result": null + } +} +``` + +## 5. Chat Agent(Workspace 左侧) + +### 5.1 获取消息历史 + +- URL: `GET /api/v1/projects/{project_id}/chat/messages?cursor=` +- 功能说明: 每个项目固定只有一个聊天会话,页面初始化时拉取该项目的消息历史,支持翻页。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "items": [ + { + "id": "m_1", + "role": "assistant", + "content": "I've initialized the React components...", + "created_at": "2026-03-11T09:00:00Z" + } + ], + "next_cursor": null + } +} +``` + +### 5.2 发送消息(流式) + +- URL: `POST /api/v1/projects/{project_id}/chat/messages` +- 功能说明: 每个项目固定只有一个聊天会话。前端发送消息后,服务端以 SSE 形式实时返回内容。 +- 请求体: + +```json +{ + "content": "Refactor project layout and keep responsive.", + "attachments": [] +} +``` + +- 响应体: `text/event-stream` + +```json +{ + "msg": "delta", + "code": 200, + "data": { + "text": "Refactoring layout..." + } +} +``` + +```json +{ + "msg": "done", + "code": 200, + "data": { + "message_id": "m_12", + "changes": [ + { + "path": "src/App.tsx", + "action": "update" + } + ] + } +} +``` + +## 6. 文件系统与代码浏览(CodeEditor 只读) + +### 6.1 获取文件树 + +- URL: `GET /api/v1/projects/{project_id}/files/tree?path=/workspace&depth=3` +- 功能说明: 填充 Explorer 树形目录。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "root": "/workspace", + "nodes": [ + { + "path": "/workspace/src", + "name": "src", + "type": "folder", + "children": [ + { + "path": "/workspace/src/App.tsx", + "name": "App.tsx", + "type": "file", + "size": 812 + } + ] + } + ] + } +} +``` + +### 6.2 读取文件 + +- URL: `GET /api/v1/projects/{project_id}/files/content?path=/workspace/src/App.tsx` +- 功能说明: 打开文件 tab 时读取内容。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "path": "/workspace/src/App.tsx", + "language": "typescript", + "content": "import { useState } from 'react';", + "sha": "8f0d2a..." + } +} +``` + +### 6.3 下载项目归档 + +- URL: `GET /api/v1/projects/{project_id}/download` +- 功能说明: 导出当前项目代码归档。浏览器直接下载 zip 文件。 +- 请求体: `无` +- 成功响应: `HTTP 200` +- 响应 Header: + +```http +Content-Type: application/zip +Content-Disposition: attachment; filename="untitled-project.zip" +``` + +- 响应体: zip 二进制文件流 + +## 7. 预览、发布、部署与分享(Workspace 顶部) + +### 7.1 启动预览 + +- URL: `POST /api/v1/projects/{project_id}/preview/start` +- 功能说明: 启动运行环境,返回预览地址。 +- 请求体: + +```json +{ + "device": "desktop", + "port": 3000 +} +``` + +- 响应体: + +```json +{ + "msg": "preview_started", + "code": 200, + "data": { + "preview_id": "pv_001", + "status": "STARTING", + "preview_url": "https://preview.example.com/pv_001" + } +} +``` + +### 7.2 查询预览状态 + +- URL: `GET /api/v1/projects/{project_id}/preview` +- 功能说明: 轮询预览是否可用。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "preview_id": "pv_001", + "status": "RUNNING", + "preview_url": "https://preview.example.com/pv_001", + "last_heartbeat_at": "2026-03-11T09:35:00Z" + } +} +``` + +### 7.4 发布(Publish) + +- URL: `POST /api/v1/projects/{project_id}/publish` +- 功能说明: 生成可公开访问的版本。 +- 请求体: + +```json +{ + "channel": "production", + "version_note": "first public release" +} +``` + +- 响应体: + +```json +{ + "msg": "published", + "code": 200, + "data": { + "release_id": "rel_001", + "public_url": "https://apps.example.com/p/p_100", + "version": "v1.0.0" + } +} +``` + +### 7.5 部署(Deploy) + +- URL: `POST /api/v1/projects/{project_id}/deployments` +- 功能说明: 触发构建与部署流水线。 +- 请求体: + +```json +{ + "environment": "prod", + "build_command": "npm run build", + "output_dir": "dist", + "env": { + "NODE_ENV": "production" + } +} +``` + +- 响应体: + +```json +{ + "msg": "deployment_started", + "code": 200, + "data": { + "deployment_id": "dep_001", + "status": "QUEUED" + } +} +``` + +### 7.6 查询部署状态 + +- URL: `GET /api/v1/deployments/{deployment_id}` +- 功能说明: 查看部署进度、日志与线上地址。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "deployment_id": "dep_001", + "status": "SUCCESS", + "logs": [ + "Install dependencies", + "Build completed", + "Upload artifacts" + ], + "live_url": "https://apps.example.com/p/p_100" + } +} +``` + +### 7.7 创建分享链接(Share) + +- URL: `POST /api/v1/projects/{project_id}/shares` +- 功能说明: 对应 **Share** 按钮,生成可访问链接。 +- 请求体: + +```json +{ + "scope": "read", + "expires_at": "2026-04-01T00:00:00Z", + "password": "" +} +``` + +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "share_id": "sh_001", + "share_url": "https://app.example.com/share/sh_001" + } +} +``` + +### 7.8 取消分享链接 + +- URL: `DELETE /api/v1/projects/{project_id}/shares/{share_id}` +- 功能说明: 失效已发出的分享链接。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "deleted", + "code": 200, + "data": { + "success": true + } +} +``` + +## 8. 附件接口(生成与聊天共用) + +### 8.1 上传附件(二进制) + +- URL: `POST /api/v1/files` +- 功能说明: 上传图片、文档等,返回 `file_id` 给生成或聊天接口引用。 +- 请求体: `multipart/form-data` + +```json +{ + "file": "(binary)", + "purpose": "chat" +} +``` + +- 响应体: + +```json +{ + "msg": "uploaded", + "code": 200, + "data": { + "file_id": "f_002", + "name": "design.png", + "size": 48213, + "mime_type": "image/png" + } +} +``` + +### 8.2 获取附件元数据 + +- URL: `GET /api/v1/files/{file_id}` +- 功能说明: 消息或任务详情页展示附件信息。 +- 请求体: `无` +- 响应体: + +```json +{ + "msg": "ok", + "code": 200, + "data": { + "file_id": "f_002", + "name": "design.png", + "size": 48213, + "mime_type": "image/png", + "download_url": "https://cdn.example.com/f_002" + } +} +``` diff --git a/app/fe/README.md b/app/fe/README.md new file mode 100644 index 0000000..79c5c9e --- /dev/null +++ b/app/fe/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/85d8c3a3-4b0c-4c95-907c-ca1833f9a119 + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/app/fe/index.html b/app/fe/index.html new file mode 100644 index 0000000..89cfd24 --- /dev/null +++ b/app/fe/index.html @@ -0,0 +1,13 @@ + + + + + + Agentland + + +
+ + + + diff --git a/app/fe/metadata.json b/app/fe/metadata.json new file mode 100644 index 0000000..271d89d --- /dev/null +++ b/app/fe/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "", + "description": "", + "requestFramePermissions": [] +} diff --git a/app/fe/mockoon/assets/untitled-project.zip b/app/fe/mockoon/assets/untitled-project.zip new file mode 100644 index 0000000..c68615f Binary files /dev/null and b/app/fe/mockoon/assets/untitled-project.zip differ diff --git a/app/fe/mockoon/mvp-environment-cli-v9.json b/app/fe/mockoon/mvp-environment-cli-v9.json new file mode 100644 index 0000000..48d0146 --- /dev/null +++ b/app/fe/mockoon/mvp-environment-cli-v9.json @@ -0,0 +1,883 @@ +{ + "uuid": "2cf6e4e1-4e10-4d9f-9f7d-5f8f7d69f9ab", + "lastMigration": 30, + "name": "agentland-mvp", + "endpointPrefix": "", + "latency": 0, + "port": 8081, + "hostname": "", + "folders": [], + "routes": [ + { + "uuid": "7d2d05ec-e1a2-4665-9e43-cde6db98cfbd", + "documentation": "Start GitHub auth", + "method": "post", + "endpoint": "/api/v1/auth/github/start", + "responses": [ + { + "uuid": "c628f4a9-e092-432c-a5f4-9d23908a0b6d", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"authorize_url\": \"https://github.com/login/oauth/authorize?client_id=mock-client\",\n \"state\": \"st_abc123\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "6febdd12-ef06-467b-a47f-e0f601c441b0", + "documentation": "Complete GitHub auth", + "method": "post", + "endpoint": "/api/v1/auth/github/callback", + "responses": [ + { + "uuid": "5dfd5d25-701b-47f4-ace9-38acc58037e5", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"user\": {\n \"id\": \"u_123\",\n \"email\": \"user@company.com\",\n \"name\": \"Alice\",\n \"avatar_url\": \"https://avatars.githubusercontent.com/u/123?v=4\",\n \"github_id\": \"1234567\",\n \"github_login\": \"alice-dev\"\n },\n \"access_token\": \"jwt_access\",\n \"refresh_token\": \"jwt_refresh\",\n \"expires_in\": 7200\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "e741f04c-6095-41f1-bf89-49777afe3fab", + "documentation": "Refresh auth token", + "method": "post", + "endpoint": "/api/v1/auth/refresh", + "responses": [ + { + "uuid": "02ec3b07-8a88-4ea9-9871-c7be1a864774", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"user\": {\n \"id\": \"u_123\",\n \"email\": \"user@company.com\",\n \"name\": \"Alice\",\n \"avatar_url\": \"https://avatars.githubusercontent.com/u/123?v=4\",\n \"plan\": \"pro\"\n },\n \"access_token\": \"jwt_access_new\",\n \"refresh_token\": \"jwt_refresh_new\",\n \"expires_in\": 7200\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "bd836ecb-8d51-448a-9322-5ba422a791fc", + "documentation": "Get current user", + "method": "get", + "endpoint": "/api/v1/auth/me", + "responses": [ + { + "uuid": "0398c663-f4b7-4e97-9faf-29ac33d0e4a9", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"id\": \"u_123\",\n \"email\": \"user@company.com\",\n \"name\": \"Alice\",\n \"avatar_url\": \"https://avatars.githubusercontent.com/u/123?v=4\",\n \"plan\": \"pro\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "0acee8bb-0163-4857-a8f3-90efdf93779f", + "documentation": "Logout", + "method": "post", + "endpoint": "/api/v1/auth/logout", + "responses": [ + { + "uuid": "378cbcc7-4987-4ea0-9d73-6f2ad9cdadc0", + "body": "{\n \"msg\": \"logged_out\",\n \"code\": 200,\n \"data\": {\n \"success\": true\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "ed30749e-7217-42bd-9136-706fffd95b8c", + "documentation": "List projects", + "method": "get", + "endpoint": "/api/v1/projects", + "responses": [ + { + "uuid": "89dc7018-f17e-4ee3-8faf-8cef870cabad", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"items\": [\n {\n \"id\": \"p_001\",\n \"name\": \"SaaS Dashboard\",\n \"status\": \"DEPLOYED\",\n \"thumbnail_url\": \"https://cdn.example.com/p1.png\",\n \"created_at\": \"2026-03-10T12:00:00Z\",\n \"updated_at\": \"2026-03-11T09:00:00Z\",\n \"is_shared\": true\n },\n {\n \"id\": \"p_100\",\n \"name\": \"Untitled Project\",\n \"status\": \"DRAFT\",\n \"thumbnail_url\": \"\",\n \"created_at\": \"2026-03-11T09:15:00Z\",\n \"updated_at\": \"2026-03-11T09:20:00Z\",\n \"is_shared\": false\n },\n {\n \"id\": \"p_233\",\n \"name\": \"Marketing Analytics\",\n \"status\": \"BUILDING\",\n \"thumbnail_url\": \"\",\n \"created_at\": \"2026-03-11T08:00:00Z\",\n \"updated_at\": \"2026-03-11T10:00:00Z\",\n \"is_shared\": false\n }\n ],\n \"pagination\": {\n \"page\": 1,\n \"page_size\": 20,\n \"total\": 3\n }\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "a2e85bd2-7f7e-4de7-97b4-843f3857df66", + "documentation": "Create project", + "method": "post", + "endpoint": "/api/v1/projects", + "responses": [ + { + "uuid": "0a4cf53f-38ce-4e59-89b6-e3bc6d3e52af", + "body": "{\n \"msg\": \"created\",\n \"code\": 200,\n \"data\": {\n \"id\": \"p_100\",\n \"name\": \"Untitled Project\",\n \"status\": \"DRAFT\",\n \"created_at\": \"2026-03-11T09:15:00Z\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "946b3299-897d-4049-8214-b38a00b28e59", + "documentation": "Get project usage", + "method": "get", + "endpoint": "/api/v1/projects/usage", + "responses": [ + { + "uuid": "66ff7a12-9b33-469a-9914-bae0d6aad42e", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"used\": 8,\n \"limit\": 12\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "9a6fee51-499f-47bd-b02e-5a26ec6d0685", + "documentation": "Get project detail", + "method": "get", + "endpoint": "/api/v1/projects/:project_id", + "responses": [ + { + "uuid": "dc6360ba-af7d-472d-8d9c-668f650b25c2", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"id\": \"p_100\",\n \"name\": \"Untitled Project\",\n \"status\": \"BUILDING\",\n \"owner_id\": \"u_123\",\n \"last_opened_at\": \"2026-03-11T09:16:00Z\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "d1c913f5-7e6a-4a54-8d6f-8c302e00b101", + "documentation": "Update project", + "method": "patch", + "endpoint": "/api/v1/projects/:project_id", + "responses": [ + { + "uuid": "4b54f4f2-2a66-4681-9c11-cdbe0ab8b102", + "body": "{\n \"msg\": \"updated\",\n \"code\": 200,\n \"data\": {\n \"id\": \"p_100\",\n \"name\": \"Marketing Analytics\",\n \"updated_at\": \"2026-03-11T09:20:00Z\",\n \"metadata\": {\n \"last_view_mode\": \"code\"\n }\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "81558ff4-9ce0-416e-bade-99b9c436536c", + "documentation": "Delete project", + "method": "delete", + "endpoint": "/api/v1/projects/:project_id", + "responses": [ + { + "uuid": "ec3ee7ee-5b7e-4b81-b3ad-88afc4f7a028", + "body": "{\n \"msg\": \"deleted\",\n \"code\": 200,\n \"data\": {\n \"success\": true\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "fbf40202-ae1f-4961-9f4b-dda6d8f8122f", + "documentation": "Create generation", + "method": "post", + "endpoint": "/api/v1/projects/:project_id/generations", + "responses": [ + { + "uuid": "4f3df358-45e4-4cea-a459-87d9a38a15c6", + "body": "{\n \"msg\": \"accepted\",\n \"code\": 200,\n \"data\": {\n \"job_id\": \"job_gen_001\",\n \"status\": \"QUEUED\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "89dd7b6d-5cb4-4af2-bfdf-60057a3786cf", + "documentation": "Get generation job status", + "method": "get", + "endpoint": "/api/v1/jobs/:job_id", + "responses": [ + { + "uuid": "af546e21-d9ea-4fa0-b5aa-cd69c65cfedd", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"job_id\": \"job_gen_001\",\n \"type\": \"APP_GENERATION\",\n \"status\": \"SUCCESS\",\n \"progress\": 100,\n \"logs\": [\n \"Scaffolding project\",\n \"Generating components\",\n \"Done\"\n ],\n \"result\": {\n \"project_id\": \"p_100\"\n }\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "f0c96ee8-9f8a-42db-9301-4d890b52c201", + "documentation": "Get chat conversations", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/chat/conversations", + "responses": [ + { + "uuid": "3140b18b-901d-4881-a6fc-c4f6d7ddc202", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"items\": [\n {\n \"id\": \"c_default\",\n \"title\": \"Default conversation\",\n \"updated_at\": \"2026-03-11T09:40:00Z\"\n },\n {\n \"id\": \"c_followup\",\n \"title\": \"Landing page refinements\",\n \"updated_at\": \"2026-03-11T09:52:00Z\"\n }\n ]\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "70698f95-6cb1-4c4f-9cb0-2f34d8992df0", + "documentation": "Get chat messages", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/chat/messages", + "responses": [ + { + "uuid": "7a8f2ad7-b8ec-4b0f-b367-084fd8efc164", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"conversation_id\": \"c_default\",\n \"items\": [\n {\n \"id\": \"m_1\",\n \"role\": \"assistant\",\n \"content\": \"I have scaffolded your app and prepared initial files.\",\n \"created_at\": \"2026-03-11T09:40:00Z\"\n }\n ],\n \"next_cursor\": null\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "1d8fe6ef-f5f9-45c1-93f7-7886f6612f6d", + "documentation": "Send chat message", + "method": "post", + "endpoint": "/api/v1/projects/:project_id/chat/messages", + "responses": [ + { + "uuid": "d9b4f66e-d0ce-4485-b08f-58d2510c769f", + "body": "data: {\"msg\":\"delta\",\"code\":200,\"data\":{\"text\":\"Refactoring layout...\"}}\n\ndata: {\"msg\":\"delta\",\"code\":200,\"data\":{\"text\":\" Updating explorer and preview wiring.\"}}\n\ndata: {\"msg\":\"done\",\"code\":200,\"data\":{\"message_id\":\"m_assistant_002\",\"changes\":[{\"path\":\"src/App.tsx\",\"action\":\"update\"},{\"path\":\"src/components/Workspace.tsx\",\"action\":\"update\"}]}}\n\n", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "text/event-stream" + }, + { + "key": "Cache-Control", + "value": "no-cache" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "07fd7a3b-4f9f-4e3e-a7d6-e5bf4a60c04f", + "documentation": "Get file tree", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/files/tree", + "responses": [ + { + "uuid": "7638f046-b55b-4fe4-8ddf-e29ebda7ff54", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"root\": \"/workspace\",\n \"nodes\": [\n {\n \"path\": \"/workspace/src\",\n \"name\": \"src\",\n \"type\": \"folder\",\n \"children\": [\n {\n \"path\": \"/workspace/src/App.tsx\",\n \"name\": \"App.tsx\",\n \"type\": \"file\"\n },\n {\n \"path\": \"/workspace/src/main.tsx\",\n \"name\": \"main.tsx\",\n \"type\": \"file\"\n }\n ]\n },\n {\n \"path\": \"/workspace/package.json\",\n \"name\": \"package.json\",\n \"type\": \"file\"\n }\n ]\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "3affd66f-3c98-4323-b8f8-6f149c35f16d", + "documentation": "Get file content", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/files/content", + "responses": [ + { + "uuid": "d1b6433d-6702-44ee-9f92-fdba8f8e0ab8", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"path\": \"/workspace/src/App.tsx\",\n \"language\": \"typescript\",\n \"content\": \"export default function App() {\\n return
Hello from Mockoon
;\\n}\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "bb6b079b-7539-4999-a651-b9c502b46826", + "documentation": "Download project archive", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/download", + "responses": [ + { + "uuid": "a27f9098-34c4-406c-b559-cf2950eaa28c", + "body": "", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/zip" + }, + { + "key": "Content-Disposition", + "value": "attachment; filename=\"untitled-project.zip\"" + } + ], + "bodyType": "FILE", + "filePath": "assets/untitled-project.zip", + "databucketID": "", + "sendFileAsBody": true, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "191fcb8f-1a52-4704-99e0-f50f589423b6", + "documentation": "Start preview", + "method": "post", + "endpoint": "/api/v1/projects/:project_id/preview/start", + "responses": [ + { + "uuid": "2f5be302-09e1-4128-9e90-632aa9a5a0bc", + "body": "{\n \"msg\": \"preview_started\",\n \"code\": 200,\n \"data\": {\n \"preview_id\": \"pv_001\",\n \"status\": \"RUNNING\",\n \"preview_url\": \"https://example.com\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "4ce8968e-3378-4628-b8cb-ef563875f0c1", + "documentation": "Get preview status", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/preview", + "responses": [ + { + "uuid": "f45d1f90-4fba-468a-a032-aac26e89ef2f", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"preview_id\": \"pv_001\",\n \"status\": \"RUNNING\",\n \"preview_url\": \"https://example.com\",\n \"last_heartbeat_at\": \"2026-03-11T09:50:00Z\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "5db83c3d-28c8-49e7-a00a-c5efbc8b6339", + "documentation": "Upload image attachment", + "method": "post", + "endpoint": "/api/v1/files", + "responses": [ + { + "uuid": "fd3a20d4-cc09-405f-81a4-1f7a89e9fc0c", + "body": "{\n \"msg\": \"uploaded\",\n \"code\": 200,\n \"data\": {\n \"file_id\": \"f_002\",\n \"name\": \"design.png\",\n \"size\": 48213,\n \"mime_type\": \"image/png\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "1e774be1-30ef-4b6f-8558-ef04766b65da", + "documentation": "Get file metadata", + "method": "get", + "endpoint": "/api/v1/files/:file_id", + "responses": [ + { + "uuid": "d56e4814-7651-4ebe-99ab-044e7e73d07b", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"file_id\": \"f_002\",\n \"name\": \"design.png\",\n \"size\": 48213,\n \"mime_type\": \"image/png\",\n \"download_url\": \"https://cdn.example.com/f_002\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + } + ], + "rootChildren": [], + "proxyMode": false, + "proxyHost": "", + "proxyRemovePrefix": false, + "tlsOptions": { + "enabled": false, + "type": "CERT", + "pfxPath": "", + "certPath": "", + "keyPath": "", + "caPath": "", + "passphrase": "" + }, + "cors": true, + "headers": [ + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization" + }, + { + "key": "Access-Control-Allow-Methods", + "value": "GET, POST, PATCH, DELETE, OPTIONS" + } + ], + "proxyReqHeaders": [], + "proxyResHeaders": [], + "callbacks": [] +} diff --git a/app/fe/mockoon/mvp-environment-cli.json b/app/fe/mockoon/mvp-environment-cli.json new file mode 100644 index 0000000..67315eb --- /dev/null +++ b/app/fe/mockoon/mvp-environment-cli.json @@ -0,0 +1,883 @@ +{ + "uuid": "2cf6e4e1-4e10-4d9f-9f7d-5f8f7d69f9ab", + "lastMigration": 35, + "name": "agentland-mvp", + "endpointPrefix": "", + "latency": 0, + "port": 8081, + "hostname": "", + "folders": [], + "routes": [ + { + "uuid": "676806c7-bcb7-4be3-a8b0-ec7b80f568a5", + "documentation": "Start GitHub auth", + "method": "post", + "endpoint": "/api/v1/auth/github/start", + "responses": [ + { + "uuid": "8d1f2f7b-76d9-4148-a8e2-57ba8339c900", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"authorize_url\": \"https://github.com/login/oauth/authorize?client_id=mock-client\",\n \"state\": \"st_abc123\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "7176d7ae-2c2c-4959-a7b7-c70764898712", + "documentation": "Complete GitHub auth", + "method": "post", + "endpoint": "/api/v1/auth/github/callback", + "responses": [ + { + "uuid": "dd4120c2-8224-49e4-af3f-75541aea1efa", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"user\": {\n \"id\": \"u_123\",\n \"email\": \"user@company.com\",\n \"name\": \"Alice\",\n \"avatar_url\": \"https://avatars.githubusercontent.com/u/123?v=4\",\n \"github_id\": \"1234567\",\n \"github_login\": \"alice-dev\"\n },\n \"access_token\": \"jwt_access\",\n \"refresh_token\": \"jwt_refresh\",\n \"expires_in\": 7200\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "b864a1d5-69de-4624-be85-f8092241a470", + "documentation": "Refresh auth token", + "method": "post", + "endpoint": "/api/v1/auth/refresh", + "responses": [ + { + "uuid": "cf8ed4de-7868-4f5a-984e-d514b9bd83e6", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"user\": {\n \"id\": \"u_123\",\n \"email\": \"user@company.com\",\n \"name\": \"Alice\",\n \"avatar_url\": \"https://avatars.githubusercontent.com/u/123?v=4\",\n \"plan\": \"pro\"\n },\n \"access_token\": \"jwt_access_new\",\n \"refresh_token\": \"jwt_refresh_new\",\n \"expires_in\": 7200\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "ce5ced67-4495-482b-bd7f-df3dbf1220ae", + "documentation": "Get current user", + "method": "get", + "endpoint": "/api/v1/auth/me", + "responses": [ + { + "uuid": "bb5e2afc-545f-49db-b85c-226741c75ded", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"id\": \"u_123\",\n \"email\": \"user@company.com\",\n \"name\": \"Alice\",\n \"avatar_url\": \"https://avatars.githubusercontent.com/u/123?v=4\",\n \"plan\": \"pro\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "ff3b02a6-4292-4f17-8965-fc07418b0d9b", + "documentation": "Logout", + "method": "post", + "endpoint": "/api/v1/auth/logout", + "responses": [ + { + "uuid": "1484f7a3-1855-41ca-bd2b-55ff9c9f926c", + "body": "{\n \"msg\": \"logged_out\",\n \"code\": 200,\n \"data\": {\n \"success\": true\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "05d40325-2c09-4ec4-9424-f0b3958d2dfd", + "documentation": "List projects", + "method": "get", + "endpoint": "/api/v1/projects", + "responses": [ + { + "uuid": "bf2ff944-7b3f-4c5e-a3c8-b9fa3136b264", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"items\": [\n {\n \"id\": \"p_001\",\n \"name\": \"SaaS Dashboard\",\n \"status\": \"DEPLOYED\",\n \"thumbnail_url\": \"https://cdn.example.com/p1.png\",\n \"created_at\": \"2026-03-10T12:00:00Z\",\n \"updated_at\": \"2026-03-11T09:00:00Z\",\n \"is_shared\": true\n },\n {\n \"id\": \"p_100\",\n \"name\": \"Untitled Project\",\n \"status\": \"DRAFT\",\n \"thumbnail_url\": \"\",\n \"created_at\": \"2026-03-11T09:15:00Z\",\n \"updated_at\": \"2026-03-11T09:20:00Z\",\n \"is_shared\": false\n },\n {\n \"id\": \"p_233\",\n \"name\": \"Marketing Analytics\",\n \"status\": \"BUILDING\",\n \"thumbnail_url\": \"\",\n \"created_at\": \"2026-03-11T08:00:00Z\",\n \"updated_at\": \"2026-03-11T10:00:00Z\",\n \"is_shared\": false\n }\n ],\n \"pagination\": {\n \"page\": 1,\n \"page_size\": 20,\n \"total\": 3\n }\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "a2e85bd2-7f7e-4de7-97b4-843f3857df66", + "documentation": "Create project", + "method": "post", + "endpoint": "/api/v1/projects", + "responses": [ + { + "uuid": "0a4cf53f-38ce-4e59-89b6-e3bc6d3e52af", + "body": "{\n \"msg\": \"created\",\n \"code\": 200,\n \"data\": {\n \"id\": \"p_100\",\n \"name\": \"Untitled Project\",\n \"status\": \"DRAFT\",\n \"created_at\": \"2026-03-11T09:15:00Z\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "0112ba61-a869-4a6e-b27e-52482b41225c", + "documentation": "Get project usage", + "method": "get", + "endpoint": "/api/v1/projects/usage", + "responses": [ + { + "uuid": "752913e6-bd34-4797-a79a-7d6e58031e57", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"used\": 8,\n \"limit\": 12\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "fdf2c135-d940-4191-9bb2-6d0d5eb5fd5a", + "documentation": "Get project detail", + "method": "get", + "endpoint": "/api/v1/projects/:project_id", + "responses": [ + { + "uuid": "7fa1b7b6-f273-4b22-a58d-f8d6efbad3cc", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"id\": \"p_100\",\n \"name\": \"Untitled Project\",\n \"status\": \"BUILDING\",\n \"owner_id\": \"u_123\",\n \"last_opened_at\": \"2026-03-11T09:16:00Z\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "d1c913f5-7e6a-4a54-8d6f-8c302e00b101", + "documentation": "Update project", + "method": "patch", + "endpoint": "/api/v1/projects/:project_id", + "responses": [ + { + "uuid": "4b54f4f2-2a66-4681-9c11-cdbe0ab8b102", + "body": "{\n \"msg\": \"updated\",\n \"code\": 200,\n \"data\": {\n \"id\": \"p_100\",\n \"name\": \"Marketing Analytics\",\n \"updated_at\": \"2026-03-11T09:20:00Z\",\n \"metadata\": {\n \"last_view_mode\": \"code\"\n }\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "c6ecda5f-2493-4e41-bc26-782333dce686", + "documentation": "Delete project", + "method": "delete", + "endpoint": "/api/v1/projects/:project_id", + "responses": [ + { + "uuid": "b38c230f-0824-4650-829a-4fdfd3778779", + "body": "{\n \"msg\": \"deleted\",\n \"code\": 200,\n \"data\": {\n \"success\": true\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "fbf40202-ae1f-4961-9f4b-dda6d8f8122f", + "documentation": "Create generation", + "method": "post", + "endpoint": "/api/v1/projects/:project_id/generations", + "responses": [ + { + "uuid": "4f3df358-45e4-4cea-a459-87d9a38a15c6", + "body": "{\n \"msg\": \"accepted\",\n \"code\": 200,\n \"data\": {\n \"job_id\": \"job_gen_001\",\n \"status\": \"QUEUED\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "89dd7b6d-5cb4-4af2-bfdf-60057a3786cf", + "documentation": "Get generation job status", + "method": "get", + "endpoint": "/api/v1/jobs/:job_id", + "responses": [ + { + "uuid": "af546e21-d9ea-4fa0-b5aa-cd69c65cfedd", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"job_id\": \"job_gen_001\",\n \"type\": \"APP_GENERATION\",\n \"status\": \"SUCCESS\",\n \"progress\": 100,\n \"logs\": [\n \"Scaffolding project\",\n \"Generating components\",\n \"Done\"\n ],\n \"result\": {\n \"project_id\": \"p_100\"\n }\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "f0c96ee8-9f8a-42db-9301-4d890b52c201", + "documentation": "Get chat conversations", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/chat/conversations", + "responses": [ + { + "uuid": "3140b18b-901d-4881-a6fc-c4f6d7ddc202", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"items\": [\n {\n \"id\": \"c_default\",\n \"title\": \"Default conversation\",\n \"updated_at\": \"2026-03-11T09:40:00Z\"\n },\n {\n \"id\": \"c_followup\",\n \"title\": \"Landing page refinements\",\n \"updated_at\": \"2026-03-11T09:52:00Z\"\n }\n ]\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "70698f95-6cb1-4c4f-9cb0-2f34d8992df0", + "documentation": "Get chat messages", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/chat/messages", + "responses": [ + { + "uuid": "7a8f2ad7-b8ec-4b0f-b367-084fd8efc164", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"conversation_id\": \"c_default\",\n \"items\": [\n {\n \"id\": \"m_1\",\n \"role\": \"assistant\",\n \"content\": \"I have scaffolded your app and prepared initial files.\",\n \"created_at\": \"2026-03-11T09:40:00Z\"\n }\n ],\n \"next_cursor\": null\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "1d8fe6ef-f5f9-45c1-93f7-7886f6612f6d", + "documentation": "Send chat message", + "method": "post", + "endpoint": "/api/v1/projects/:project_id/chat/messages", + "responses": [ + { + "uuid": "d9b4f66e-d0ce-4485-b08f-58d2510c769f", + "body": "data: {\"msg\":\"delta\",\"code\":200,\"data\":{\"text\":\"Refactoring layout...\"}}\n\ndata: {\"msg\":\"delta\",\"code\":200,\"data\":{\"text\":\" Updating explorer and preview wiring.\"}}\n\ndata: {\"msg\":\"done\",\"code\":200,\"data\":{\"message_id\":\"m_assistant_002\",\"changes\":[{\"path\":\"src/App.tsx\",\"action\":\"update\"},{\"path\":\"src/components/Workspace.tsx\",\"action\":\"update\"}]}}\n\n", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "text/event-stream" + }, + { + "key": "Cache-Control", + "value": "no-cache" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "07fd7a3b-4f9f-4e3e-a7d6-e5bf4a60c04f", + "documentation": "Get file tree", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/files/tree", + "responses": [ + { + "uuid": "7638f046-b55b-4fe4-8ddf-e29ebda7ff54", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"root\": \"/workspace\",\n \"nodes\": [\n {\n \"path\": \"/workspace/src\",\n \"name\": \"src\",\n \"type\": \"folder\",\n \"children\": [\n {\n \"path\": \"/workspace/src/App.tsx\",\n \"name\": \"App.tsx\",\n \"type\": \"file\"\n },\n {\n \"path\": \"/workspace/src/main.tsx\",\n \"name\": \"main.tsx\",\n \"type\": \"file\"\n }\n ]\n },\n {\n \"path\": \"/workspace/package.json\",\n \"name\": \"package.json\",\n \"type\": \"file\"\n }\n ]\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "3affd66f-3c98-4323-b8f8-6f149c35f16d", + "documentation": "Get file content", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/files/content", + "responses": [ + { + "uuid": "d1b6433d-6702-44ee-9f92-fdba8f8e0ab8", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"path\": \"/workspace/src/App.tsx\",\n \"language\": \"typescript\",\n \"content\": \"export default function App() {\\n return
Hello from Mockoon
;\\n}\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "b0d4acd3-1557-44cd-906b-b014d8ca25e0", + "documentation": "Download project archive", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/download", + "responses": [ + { + "uuid": "61952266-d3dc-4dc2-afb8-3215a655938b", + "body": "", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/zip" + }, + { + "key": "Content-Disposition", + "value": "attachment; filename=\"untitled-project.zip\"" + } + ], + "bodyType": "FILE", + "filePath": "assets/untitled-project.zip", + "databucketID": "", + "sendFileAsBody": true, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "191fcb8f-1a52-4704-99e0-f50f589423b6", + "documentation": "Start preview", + "method": "post", + "endpoint": "/api/v1/projects/:project_id/preview/start", + "responses": [ + { + "uuid": "2f5be302-09e1-4128-9e90-632aa9a5a0bc", + "body": "{\n \"msg\": \"preview_started\",\n \"code\": 200,\n \"data\": {\n \"preview_id\": \"pv_001\",\n \"status\": \"RUNNING\",\n \"preview_url\": \"https://example.com\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "4ce8968e-3378-4628-b8cb-ef563875f0c1", + "documentation": "Get preview status", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/preview", + "responses": [ + { + "uuid": "f45d1f90-4fba-468a-a032-aac26e89ef2f", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"preview_id\": \"pv_001\",\n \"status\": \"RUNNING\",\n \"preview_url\": \"https://example.com\",\n \"last_heartbeat_at\": \"2026-03-11T09:50:00Z\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "f5c95072-9322-4136-b553-18488e87d236", + "documentation": "Upload image attachment", + "method": "post", + "endpoint": "/api/v1/files", + "responses": [ + { + "uuid": "3994dbfb-1087-423f-aec9-2488c22f87ad", + "body": "{\n \"msg\": \"uploaded\",\n \"code\": 200,\n \"data\": {\n \"file_id\": \"f_002\",\n \"name\": \"design.png\",\n \"size\": 48213,\n \"mime_type\": \"image/png\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "8e4743f1-4047-4470-aefe-c5d9418ac508", + "documentation": "Get file metadata", + "method": "get", + "endpoint": "/api/v1/files/:file_id", + "responses": [ + { + "uuid": "cd3a9758-95d1-4ad6-8aad-5de1a8a43f07", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"file_id\": \"f_002\",\n \"name\": \"design.png\",\n \"size\": 48213,\n \"mime_type\": \"image/png\",\n \"download_url\": \"https://cdn.example.com/f_002\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + } + ], + "rootChildren": [], + "proxyMode": false, + "proxyHost": "", + "proxyRemovePrefix": false, + "tlsOptions": { + "enabled": false, + "type": "CERT", + "pfxPath": "", + "certPath": "", + "keyPath": "", + "caPath": "", + "passphrase": "" + }, + "cors": true, + "headers": [ + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization" + }, + { + "key": "Access-Control-Allow-Methods", + "value": "GET, POST, PATCH, DELETE, OPTIONS" + } + ], + "proxyReqHeaders": [], + "proxyResHeaders": [], + "callbacks": [] +} diff --git a/app/fe/mockoon/mvp-environment.json b/app/fe/mockoon/mvp-environment.json new file mode 100644 index 0000000..3286ace --- /dev/null +++ b/app/fe/mockoon/mvp-environment.json @@ -0,0 +1,891 @@ +{ + "source": "mockoon:1.25.0", + "data": [ + { + "type": "environment", + "item": { + "uuid": "2cf6e4e1-4e10-4d9f-9f7d-5f8f7d69f9ab", + "lastMigration": 35, + "name": "agentland-mvp", + "endpointPrefix": "", + "latency": 0, + "port": 8081, + "hostname": "", + "folders": [], + "routes": [ + { + "uuid": "501dc486-f32d-4770-b76d-1f9e383a5399", + "documentation": "Start GitHub auth", + "method": "post", + "endpoint": "/api/v1/auth/github/start", + "responses": [ + { + "uuid": "f74b01a2-2a5b-497d-a538-3d0be32a1fc0", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"authorize_url\": \"https://github.com/login/oauth/authorize?client_id=mock-client\",\n \"state\": \"st_abc123\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "0b531785-4fd9-42ed-9b24-98df3a764b41", + "documentation": "Complete GitHub auth", + "method": "post", + "endpoint": "/api/v1/auth/github/callback", + "responses": [ + { + "uuid": "627d39d7-c3e0-4af5-88f0-e294fc25f7fd", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"user\": {\n \"id\": \"u_123\",\n \"email\": \"user@company.com\",\n \"name\": \"Alice\",\n \"avatar_url\": \"https://avatars.githubusercontent.com/u/123?v=4\",\n \"github_id\": \"1234567\",\n \"github_login\": \"alice-dev\"\n },\n \"access_token\": \"jwt_access\",\n \"refresh_token\": \"jwt_refresh\",\n \"expires_in\": 7200\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "622d1b48-b785-416d-ac8a-0f4d263d2676", + "documentation": "Refresh auth token", + "method": "post", + "endpoint": "/api/v1/auth/refresh", + "responses": [ + { + "uuid": "65517458-164c-4075-8931-c90314aaf7d3", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"user\": {\n \"id\": \"u_123\",\n \"email\": \"user@company.com\",\n \"name\": \"Alice\",\n \"avatar_url\": \"https://avatars.githubusercontent.com/u/123?v=4\",\n \"plan\": \"pro\"\n },\n \"access_token\": \"jwt_access_new\",\n \"refresh_token\": \"jwt_refresh_new\",\n \"expires_in\": 7200\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "d3f0ac21-362e-499e-a0b0-c4c833f83d5f", + "documentation": "Get current user", + "method": "get", + "endpoint": "/api/v1/auth/me", + "responses": [ + { + "uuid": "21942224-54fa-4bf1-852a-fc09b4b3e2f0", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"id\": \"u_123\",\n \"email\": \"user@company.com\",\n \"name\": \"Alice\",\n \"avatar_url\": \"https://avatars.githubusercontent.com/u/123?v=4\",\n \"plan\": \"pro\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "1714993e-2f8a-4d78-9cee-2e4e56ca90c5", + "documentation": "Logout", + "method": "post", + "endpoint": "/api/v1/auth/logout", + "responses": [ + { + "uuid": "70c0e8c4-e9c7-49c8-b828-9b616bb3940b", + "body": "{\n \"msg\": \"logged_out\",\n \"code\": 200,\n \"data\": {\n \"success\": true\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "99828c7b-60b2-4261-b886-4b20e6fcbd3f", + "documentation": "List projects", + "method": "get", + "endpoint": "/api/v1/projects", + "responses": [ + { + "uuid": "b3cba382-f0c9-4c57-be47-071703c9e10c", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"items\": [\n {\n \"id\": \"p_001\",\n \"name\": \"SaaS Dashboard\",\n \"status\": \"DEPLOYED\",\n \"thumbnail_url\": \"https://cdn.example.com/p1.png\",\n \"created_at\": \"2026-03-10T12:00:00Z\",\n \"updated_at\": \"2026-03-11T09:00:00Z\",\n \"is_shared\": true\n },\n {\n \"id\": \"p_100\",\n \"name\": \"Untitled Project\",\n \"status\": \"DRAFT\",\n \"thumbnail_url\": \"\",\n \"created_at\": \"2026-03-11T09:15:00Z\",\n \"updated_at\": \"2026-03-11T09:20:00Z\",\n \"is_shared\": false\n },\n {\n \"id\": \"p_233\",\n \"name\": \"Marketing Analytics\",\n \"status\": \"BUILDING\",\n \"thumbnail_url\": \"\",\n \"created_at\": \"2026-03-11T08:00:00Z\",\n \"updated_at\": \"2026-03-11T10:00:00Z\",\n \"is_shared\": false\n }\n ],\n \"pagination\": {\n \"page\": 1,\n \"page_size\": 20,\n \"total\": 3\n }\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "a2e85bd2-7f7e-4de7-97b4-843f3857df66", + "documentation": "Create project", + "method": "post", + "endpoint": "/api/v1/projects", + "responses": [ + { + "uuid": "0a4cf53f-38ce-4e59-89b6-e3bc6d3e52af", + "body": "{\n \"msg\": \"created\",\n \"code\": 200,\n \"data\": {\n \"id\": \"p_100\",\n \"name\": \"Untitled Project\",\n \"status\": \"DRAFT\",\n \"created_at\": \"2026-03-11T09:15:00Z\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "4e7f8ebc-d405-4ebe-ad5a-7a5564ee9d16", + "documentation": "Get project usage", + "method": "get", + "endpoint": "/api/v1/projects/usage", + "responses": [ + { + "uuid": "e869501c-2eac-44a0-ac23-29e04ee3f1a3", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"used\": 8,\n \"limit\": 12\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "e76bb9aa-b1d7-40b1-85ba-93912071b3f4", + "documentation": "Get project detail", + "method": "get", + "endpoint": "/api/v1/projects/:project_id", + "responses": [ + { + "uuid": "77036508-0e56-4701-825d-e18e376e68bf", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"id\": \"p_100\",\n \"name\": \"Untitled Project\",\n \"status\": \"BUILDING\",\n \"owner_id\": \"u_123\",\n \"last_opened_at\": \"2026-03-11T09:16:00Z\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "d1c913f5-7e6a-4a54-8d6f-8c302e00b101", + "documentation": "Update project", + "method": "patch", + "endpoint": "/api/v1/projects/:project_id", + "responses": [ + { + "uuid": "4b54f4f2-2a66-4681-9c11-cdbe0ab8b102", + "body": "{\n \"msg\": \"updated\",\n \"code\": 200,\n \"data\": {\n \"id\": \"p_100\",\n \"name\": \"Marketing Analytics\",\n \"updated_at\": \"2026-03-11T09:20:00Z\",\n \"metadata\": {\n \"last_view_mode\": \"code\"\n }\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "de154cda-6195-4b5c-83dc-1510dfead3eb", + "documentation": "Delete project", + "method": "delete", + "endpoint": "/api/v1/projects/:project_id", + "responses": [ + { + "uuid": "90112ffc-d7d6-4cbf-b7d5-da25155c527a", + "body": "{\n \"msg\": \"deleted\",\n \"code\": 200,\n \"data\": {\n \"success\": true\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "fbf40202-ae1f-4961-9f4b-dda6d8f8122f", + "documentation": "Create generation", + "method": "post", + "endpoint": "/api/v1/projects/:project_id/generations", + "responses": [ + { + "uuid": "4f3df358-45e4-4cea-a459-87d9a38a15c6", + "body": "{\n \"msg\": \"accepted\",\n \"code\": 200,\n \"data\": {\n \"job_id\": \"job_gen_001\",\n \"status\": \"QUEUED\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "89dd7b6d-5cb4-4af2-bfdf-60057a3786cf", + "documentation": "Get generation job status", + "method": "get", + "endpoint": "/api/v1/jobs/:job_id", + "responses": [ + { + "uuid": "af546e21-d9ea-4fa0-b5aa-cd69c65cfedd", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"job_id\": \"job_gen_001\",\n \"type\": \"APP_GENERATION\",\n \"status\": \"SUCCESS\",\n \"progress\": 100,\n \"logs\": [\n \"Scaffolding project\",\n \"Generating components\",\n \"Done\"\n ],\n \"result\": {\n \"project_id\": \"p_100\"\n }\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "f0c96ee8-9f8a-42db-9301-4d890b52c201", + "documentation": "Get chat conversations", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/chat/conversations", + "responses": [ + { + "uuid": "3140b18b-901d-4881-a6fc-c4f6d7ddc202", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"items\": [\n {\n \"id\": \"c_default\",\n \"title\": \"Default conversation\",\n \"updated_at\": \"2026-03-11T09:40:00Z\"\n },\n {\n \"id\": \"c_followup\",\n \"title\": \"Landing page refinements\",\n \"updated_at\": \"2026-03-11T09:52:00Z\"\n }\n ]\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "70698f95-6cb1-4c4f-9cb0-2f34d8992df0", + "documentation": "Get chat messages", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/chat/messages", + "responses": [ + { + "uuid": "7a8f2ad7-b8ec-4b0f-b367-084fd8efc164", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"conversation_id\": \"c_default\",\n \"items\": [\n {\n \"id\": \"m_1\",\n \"role\": \"assistant\",\n \"content\": \"I have scaffolded your app and prepared initial files.\",\n \"created_at\": \"2026-03-11T09:40:00Z\"\n }\n ],\n \"next_cursor\": null\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "1d8fe6ef-f5f9-45c1-93f7-7886f6612f6d", + "documentation": "Send chat message", + "method": "post", + "endpoint": "/api/v1/projects/:project_id/chat/messages", + "responses": [ + { + "uuid": "d9b4f66e-d0ce-4485-b08f-58d2510c769f", + "body": "data: {\"msg\":\"delta\",\"code\":200,\"data\":{\"text\":\"Refactoring layout...\"}}\n\ndata: {\"msg\":\"delta\",\"code\":200,\"data\":{\"text\":\" Updating explorer and preview wiring.\"}}\n\ndata: {\"msg\":\"done\",\"code\":200,\"data\":{\"message_id\":\"m_assistant_002\",\"changes\":[{\"path\":\"src/App.tsx\",\"action\":\"update\"},{\"path\":\"src/components/Workspace.tsx\",\"action\":\"update\"}]}}\n\n", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "text/event-stream" + }, + { + "key": "Cache-Control", + "value": "no-cache" + }, + { + "key": "Connection", + "value": "keep-alive" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "07fd7a3b-4f9f-4e3e-a7d6-e5bf4a60c04f", + "documentation": "Get file tree", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/files/tree", + "responses": [ + { + "uuid": "7638f046-b55b-4fe4-8ddf-e29ebda7ff54", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"root\": \"/workspace\",\n \"nodes\": [\n {\n \"path\": \"/workspace/src\",\n \"name\": \"src\",\n \"type\": \"folder\",\n \"children\": [\n {\n \"path\": \"/workspace/src/App.tsx\",\n \"name\": \"App.tsx\",\n \"type\": \"file\"\n },\n {\n \"path\": \"/workspace/src/main.tsx\",\n \"name\": \"main.tsx\",\n \"type\": \"file\"\n }\n ]\n },\n {\n \"path\": \"/workspace/package.json\",\n \"name\": \"package.json\",\n \"type\": \"file\"\n }\n ]\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "3affd66f-3c98-4323-b8f8-6f149c35f16d", + "documentation": "Get file content", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/files/content", + "responses": [ + { + "uuid": "d1b6433d-6702-44ee-9f92-fdba8f8e0ab8", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"path\": \"/workspace/src/App.tsx\",\n \"language\": \"typescript\",\n \"content\": \"export default function App() {\\n return
Hello from Mockoon
;\\n}\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "50b023c6-db6b-42c3-b9d1-116ee69be15e", + "documentation": "Download project archive", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/download", + "responses": [ + { + "uuid": "f93c5119-de59-475d-a93f-660b34fb9edf", + "body": "", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/zip" + }, + { + "key": "Content-Disposition", + "value": "attachment; filename=\"untitled-project.zip\"" + } + ], + "bodyType": "FILE", + "filePath": "assets/untitled-project.zip", + "databucketID": "", + "sendFileAsBody": true, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "191fcb8f-1a52-4704-99e0-f50f589423b6", + "documentation": "Start preview", + "method": "post", + "endpoint": "/api/v1/projects/:project_id/preview/start", + "responses": [ + { + "uuid": "2f5be302-09e1-4128-9e90-632aa9a5a0bc", + "body": "{\n \"msg\": \"preview_started\",\n \"code\": 200,\n \"data\": {\n \"preview_id\": \"pv_001\",\n \"status\": \"RUNNING\",\n \"preview_url\": \"https://example.com\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "4ce8968e-3378-4628-b8cb-ef563875f0c1", + "documentation": "Get preview status", + "method": "get", + "endpoint": "/api/v1/projects/:project_id/preview", + "responses": [ + { + "uuid": "f45d1f90-4fba-468a-a032-aac26e89ef2f", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"preview_id\": \"pv_001\",\n \"status\": \"RUNNING\",\n \"preview_url\": \"https://example.com\",\n \"last_heartbeat_at\": \"2026-03-11T09:50:00Z\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "f5016e5e-1e67-4983-929c-2de71ab30ce8", + "documentation": "Upload image attachment", + "method": "post", + "endpoint": "/api/v1/files", + "responses": [ + { + "uuid": "78727b92-1fe5-48b2-a74d-5a50193ab940", + "body": "{\n \"msg\": \"uploaded\",\n \"code\": 200,\n \"data\": {\n \"file_id\": \"f_002\",\n \"name\": \"design.png\",\n \"size\": 48213,\n \"mime_type\": \"image/png\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + }, + { + "uuid": "432fd3ef-7bfa-47b5-924f-2d16a3f845c6", + "documentation": "Get file metadata", + "method": "get", + "endpoint": "/api/v1/files/:file_id", + "responses": [ + { + "uuid": "c2620da9-0178-4ce3-ae44-fd09b66d036e", + "body": "{\n \"msg\": \"ok\",\n \"code\": 200,\n \"data\": {\n \"file_id\": \"f_002\",\n \"name\": \"design.png\",\n \"size\": 48213,\n \"mime_type\": \"image/png\",\n \"download_url\": \"https://cdn.example.com/f_002\"\n }\n}", + "latency": 0, + "statusCode": 200, + "label": "OK", + "headers": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "bodyType": "INLINE", + "filePath": "", + "databucketID": "", + "sendFileAsBody": false, + "rules": [], + "rulesOperator": "OR", + "disableTemplating": false + } + ], + "responseMode": null, + "streamingMode": null, + "streamingInterval": 0, + "rules": [], + "rulesOperator": "OR", + "enabled": true, + "randomResponse": false, + "sequentialResponse": false + } + ], + "rootChildren": [], + "proxyMode": false, + "proxyHost": "", + "proxyRemovePrefix": false, + "tlsOptions": { + "enabled": false, + "type": "CERT", + "pfxPath": "", + "certPath": "", + "keyPath": "", + "caPath": "", + "passphrase": "" + }, + "cors": true, + "headers": [ + { + "key": "Access-Control-Allow-Origin", + "value": "*" + }, + { + "key": "Access-Control-Allow-Headers", + "value": "Content-Type, Authorization" + }, + { + "key": "Access-Control-Allow-Methods", + "value": "GET, POST, PATCH, DELETE, OPTIONS" + } + ], + "proxyReqHeaders": [], + "proxyResHeaders": [], + "callbacks": [] + } + } + ] +} diff --git a/app/fe/package-lock.json b/app/fe/package-lock.json new file mode 100644 index 0000000..e073b29 --- /dev/null +++ b/app/fe/package-lock.json @@ -0,0 +1,5266 @@ +{ + "name": "react-example", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-example", + "version": "0.0.0", + "dependencies": { + "@google/genai": "^1.29.0", + "@monaco-editor/react": "^4.7.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "better-sqlite3": "^12.4.1", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.2.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.44.0", + "resolved": "https://registry.npmmirror.com/@google/genai/-/genai-1.44.0.tgz", + "integrity": "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmmirror.com/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmmirror.com/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.35.2", + "resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.35.2.tgz", + "integrity": "sha512-dhfuEMaNo0hc+AEqyHiIfiJRNb9U9UQutE9FoKm5pjf7CMitp9xPEF1iWZihR1q86LBmo6EJ7S8cN8QXEy49AA==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.35.2", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.546.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.546.0.tgz", + "integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmmirror.com/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmmirror.com/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/motion": { + "version": "12.35.2", + "resolved": "https://registry.npmmirror.com/motion/-/motion-12.35.2.tgz", + "integrity": "sha512-8zCi1DkNyU6a/tgEHn/GnnXZDcaMpDHbDOGORY1Rg/6lcNMSOuvwDB3i4hMSOvxqMWArc/vrGaw/Xek1OP69/A==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.35.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.35.2", + "resolved": "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.35.2.tgz", + "integrity": "sha512-pWXFMTwvGDbx1Fe9YL5HZebv2NhvGBzRtiNUv58aoK7+XrsuaydQ0JGRKK2r+bTKlwgSWwWxHbP5249Qr/BNpg==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmmirror.com/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.2.tgz", + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmmirror.com/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + } + } +} diff --git a/app/fe/package.json b/app/fe/package.json new file mode 100644 index 0000000..4568286 --- /dev/null +++ b/app/fe/package.json @@ -0,0 +1,36 @@ +{ + "name": "react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --port=3000 --host=0.0.0.0", + "build": "vite build", + "preview": "vite preview", + "clean": "rm -rf dist", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@google/genai": "^1.29.0", + "@monaco-editor/react": "^4.7.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "better-sqlite3": "^12.4.1", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.2.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/app/fe/src/App.tsx b/app/fe/src/App.tsx new file mode 100644 index 0000000..21f81b0 --- /dev/null +++ b/app/fe/src/App.tsx @@ -0,0 +1,429 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useState } from 'react'; +import { AnimatePresence } from 'motion/react'; +import Login from './components/Login'; +import Dashboard from './components/Dashboard'; +import Workspace from './components/Workspace'; +import Projects from './components/Projects'; +import { I18nProvider } from './i18n'; +import { + completeGithubAuth, + createGeneration, + createProject, + getCurrentUser, + logout, + refreshAuthToken, + startGithubAuth, + uploadImageAttachment, + type GenerationAttachment, + type UserProfile, +} from './api'; + +type AppPage = 'login' | 'dashboard' | 'workspace' | 'projects'; + +type ActiveProject = { + id: string; + name: string; + prompt: string; + viewMode: 'preview' | 'code'; + generationJobId?: string; +}; + +type AuthBootstrapState = { + accessToken: string; + refreshToken: string; + user: UserProfile; +}; + +const ACCESS_TOKEN_KEY = 'access_token'; +const REFRESH_TOKEN_KEY = 'refresh_token'; +const USER_PROFILE_KEY = 'current_user'; +const DEEP_ENABLED_KEY = 'deep_enabled'; +const AUTH_CALLBACK_PATH = '/auth/github/callback'; + +let pendingOAuthBootstrap: Promise | null = null; + +function persistSession(session: AuthBootstrapState) { + localStorage.setItem(ACCESS_TOKEN_KEY, session.accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, session.refreshToken); + localStorage.setItem(USER_PROFILE_KEY, JSON.stringify(session.user)); +} + +function clearStoredSession() { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_PROFILE_KEY); +} + +function readStoredUser(): UserProfile | null { + const raw = localStorage.getItem(USER_PROFILE_KEY); + if (!raw) { + return null; + } + + try { + return JSON.parse(raw) as UserProfile; + } catch { + localStorage.removeItem(USER_PROFILE_KEY); + return null; + } +} + +function readStoredDeepEnabled(): boolean { + if (typeof window === 'undefined') { + return false; + } + return window.localStorage.getItem(DEEP_ENABLED_KEY) === 'true'; +} + +async function bootstrapOAuthSession(code: string, state: string): Promise { + const callback = await completeGithubAuth(code, state); + const user = await getCurrentUser(callback.access_token); + const session = { + accessToken: callback.access_token, + refreshToken: callback.refresh_token, + user, + }; + + persistSession(session); + return session; +} + +function AppContent() { + const [currentPage, setCurrentPage] = useState('login'); + const [accessToken, setAccessToken] = useState(undefined); + const [refreshToken, setRefreshToken] = useState(undefined); + const [currentUser, setCurrentUser] = useState(null); + const [activeProject, setActiveProject] = useState(null); + const [deepEnabled, setDeepEnabled] = useState(readStoredDeepEnabled); + + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [isRestoringSession, setIsRestoringSession] = useState(true); + const [authError, setAuthError] = useState(null); + + const [isGenerating, setIsGenerating] = useState(false); + const [generationError, setGenerationError] = useState(null); + + useEffect(() => { + window.localStorage.setItem(DEEP_ENABLED_KEY, deepEnabled ? 'true' : 'false'); + }, [deepEnabled]); + + useEffect(() => { + let canceled = false; + + const applySession = (session: AuthBootstrapState) => { + if (canceled) { + return; + } + setAccessToken(session.accessToken); + setRefreshToken(session.refreshToken); + setCurrentUser(session.user); + setGenerationError(null); + setCurrentPage('dashboard'); + }; + + const resetToLogin = (message?: string) => { + clearStoredSession(); + if (canceled) { + return; + } + setAccessToken(undefined); + setRefreshToken(undefined); + setCurrentUser(null); + setActiveProject(null); + setCurrentPage('login'); + if (message) { + setAuthError(message); + } + }; + + const completeOAuthCallback = async () => { + if (window.location.pathname !== AUTH_CALLBACK_PATH) { + return false; + } + + const callbackSearch = window.location.search; + window.history.replaceState({}, document.title, '/'); + + const searchParams = new URLSearchParams(callbackSearch); + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const oauthError = searchParams.get('error'); + const oauthErrorDescription = searchParams.get('error_description'); + + if (oauthError) { + resetToLogin(oauthErrorDescription || oauthError); + return true; + } + + if (!code || !state) { + resetToLogin('Missing GitHub callback parameters'); + return true; + } + + if (!canceled) { + setIsAuthenticating(true); + setAuthError(null); + } + + try { + pendingOAuthBootstrap ??= bootstrapOAuthSession(code, state); + const session = await pendingOAuthBootstrap; + applySession(session); + } catch (error) { + pendingOAuthBootstrap = null; + resetToLogin((error as Error).message || 'Failed to authenticate'); + } finally { + pendingOAuthBootstrap = null; + if (!canceled) { + setIsAuthenticating(false); + setIsRestoringSession(false); + } + } + + return true; + }; + + const restoreSession = async () => { + const handledCallback = await completeOAuthCallback(); + if (handledCallback) { + return; + } + + if (pendingOAuthBootstrap) { + try { + const session = await pendingOAuthBootstrap; + applySession(session); + } catch (error) { + resetToLogin((error as Error).message || 'Failed to authenticate'); + } finally { + pendingOAuthBootstrap = null; + if (!canceled) { + setIsRestoringSession(false); + } + } + return; + } + + const storedAccessToken = localStorage.getItem(ACCESS_TOKEN_KEY) ?? undefined; + const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) ?? undefined; + const storedUser = readStoredUser(); + + if (!storedAccessToken && !storedRefreshToken) { + if (!canceled) { + setCurrentUser(storedUser); + setIsRestoringSession(false); + } + return; + } + + if (storedAccessToken) { + try { + const user = await getCurrentUser(storedAccessToken); + const session = { + accessToken: storedAccessToken, + refreshToken: storedRefreshToken ?? '', + user, + }; + localStorage.setItem(USER_PROFILE_KEY, JSON.stringify(user)); + applySession(session); + if (!canceled) { + setIsRestoringSession(false); + } + return; + } catch { + localStorage.removeItem(ACCESS_TOKEN_KEY); + } + } + + if (!storedRefreshToken) { + resetToLogin(); + if (!canceled) { + setIsRestoringSession(false); + } + return; + } + + try { + const refreshed = await refreshAuthToken(storedRefreshToken); + const user = await getCurrentUser(refreshed.access_token); + const session = { + accessToken: refreshed.access_token, + refreshToken: refreshed.refresh_token, + user, + }; + persistSession(session); + applySession(session); + } catch (error) { + resetToLogin((error as Error).message || undefined); + } finally { + if (!canceled) { + setIsRestoringSession(false); + } + } + }; + + void restoreSession(); + + return () => { + canceled = true; + }; + }, []); + + + const handleGenerate = async (prompt: string, attachments: GenerationAttachment[] = []) => { + const normalizedPrompt = prompt.trim(); + if (!normalizedPrompt || isGenerating) { + return; + } + + setIsGenerating(true); + setGenerationError(null); + + try { + const project = await createProject( + { + name: 'Untitled Project', + template: 'blank', + }, + accessToken, + ); + + const generation = await createGeneration(project.id, normalizedPrompt, attachments, accessToken, deepEnabled); + + setActiveProject({ + id: project.id, + name: project.name, + prompt: normalizedPrompt, + viewMode: 'preview', + generationJobId: generation.job_id, + }); + setCurrentPage('workspace'); + } catch (error) { + setGenerationError((error as Error).message || 'Failed to generate app'); + } finally { + setIsGenerating(false); + } + }; + + const handleUploadImage = async (file: File) => { + return uploadImageAttachment(file, accessToken); + }; + + const handleLogin = async () => { + setIsAuthenticating(true); + setAuthError(null); + + try { + const redirectUri = `${window.location.origin}${AUTH_CALLBACK_PATH}`; + const start = await startGithubAuth(redirectUri); + window.location.assign(start.authorize_url); + } catch (error) { + setAuthError((error as Error).message || 'Failed to authenticate'); + setIsAuthenticating(false); + } + }; + + const handleLogout = async () => { + try { + const storedRefreshToken = refreshToken ?? localStorage.getItem(REFRESH_TOKEN_KEY) ?? undefined; + const storedAccessToken = accessToken ?? localStorage.getItem(ACCESS_TOKEN_KEY) ?? undefined; + if (storedRefreshToken) { + await logout(storedRefreshToken, storedAccessToken); + } + } catch { + // Clear local session even if logout fails. + } finally { + pendingOAuthBootstrap = null; + clearStoredSession(); + setIsAuthenticating(false); + setIsRestoringSession(false); + setAuthError(null); + setAccessToken(undefined); + setRefreshToken(undefined); + setCurrentUser(null); + setActiveProject(null); + setCurrentPage('login'); + } + }; + + const goDashboard = () => setCurrentPage('dashboard'); + const openProjectInWorkspace = (project: { id: string; name: string; viewMode?: 'preview' | 'code' }) => { + setActiveProject({ + id: project.id, + name: project.name, + prompt: '', + viewMode: project.viewMode ?? 'preview', + generationJobId: undefined, + }); + setCurrentPage('workspace'); + }; + + return ( +
+ + {currentPage === 'login' && ( + + )} + + {currentPage === 'dashboard' && ( + setCurrentPage('projects')} + onUploadImage={handleUploadImage} + deepEnabled={deepEnabled} + onDeepEnabledChange={setDeepEnabled} + isGenerating={isGenerating} + generationError={generationError} + currentUser={currentUser} + /> + )} + + {currentPage === 'workspace' && activeProject && ( + setCurrentPage('projects')} + onLogout={handleLogout} + projectId={activeProject.id} + projectName={activeProject.name} + initialPrompt={activeProject.prompt} + initialViewMode={activeProject.viewMode} + generationJobId={activeProject.generationJobId} + accessToken={accessToken} + deepEnabled={deepEnabled} + onDeepEnabledChange={setDeepEnabled} + currentUser={currentUser} + /> + )} + + {currentPage === 'projects' && ( + setCurrentPage('projects')} + onLogout={handleLogout} + accessToken={accessToken} + currentUser={currentUser} + /> + )} + +
+ ); +} + +export default function App() { + return ( + + + + ); +} diff --git a/app/fe/src/api.ts b/app/fe/src/api.ts new file mode 100644 index 0000000..e188083 --- /dev/null +++ b/app/fe/src/api.ts @@ -0,0 +1,741 @@ +export type ApiEnvelope = { + msg: string; + code: number; + data: T; +}; + +export type UserProfile = { + id: string; + email: string; + name: string; + avatar_url?: string; + plan?: string; +}; + +export type AuthStartResult = { + authorize_url: string; + state: string; +}; + +export type AuthCallbackResult = { + user: UserProfile; + access_token: string; + refresh_token: string; + expires_in: number; +}; + +export type AuthRefreshResult = { + access_token: string; + refresh_token: string; + expires_in: number; +}; + +export type Project = { + id: string; + name: string; + status: string; + thumbnail_url?: string; + owner_id?: string; + last_opened_at?: string; + created_at?: string; + updated_at?: string; + is_shared?: boolean; + metadata?: { + last_view_mode?: 'preview' | 'code'; + }; +}; + +export type ProjectUpdateResult = { + id: string; + name?: string; + updated_at?: string; + metadata?: { + last_view_mode?: 'preview' | 'code'; + }; +}; + +export type GenerationAttachment = { + file_id: string; + name: string; +}; + +export type GenerationJob = { + job_id: string; + status: string; +}; + +export type ProjectListResult = { + items: Project[]; + pagination?: { + page: number; + page_size: number; + total: number; + }; +}; + +export type ProjectUsage = { + used: number; + limit: number; +}; + +export type JobDetail = { + job_id: string; + type?: string; + status: string; + progress?: number; + logs?: string[]; + result?: unknown; +}; + +export type ChatMessage = { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + created_at?: string; +}; + +export type ChatHistory = { + items: ChatMessage[]; + next_cursor?: string | null; +}; + +export type ChatSendResult = { + user_message?: ChatMessage; + assistant_message?: ChatMessage; + changes?: Array<{ path: string; action: string }>; +}; + +type ChatDeltaEvent = ApiEnvelope<{ + text: string; +}>; + +type ChatDoneEvent = ApiEnvelope<{ + message_id?: string; + changes?: Array<{ path: string; action: string }>; +}>; + +type SendChatMessageOptions = { + onDelta?: (fullText: string, deltaText: string) => void; + deep?: boolean; +}; + +export type FileTreeNode = { + path: string; + name: string; + type: 'folder' | 'file'; + size?: number; + children?: FileTreeNode[]; +}; + +export type FileTreeResult = { + root: string; + nodes: FileTreeNode[]; +}; + +export type FileContentResult = { + path: string; + language?: string; + content: string; + sha?: string; +}; + +export type FileDownloadResult = { + file_name: string; +}; + +export type FileUploadResult = { + file_id: string; + name: string; + size: number; + mime_type: string; + download_url?: string; +}; + +export type PreviewResult = { + preview_id?: string; + status?: string; + preview_url?: string; + last_heartbeat_at?: string; +}; + +const DEFAULT_BASE_URL = '/api/v1'; + +function normalizeBaseUrl() { + const raw = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? DEFAULT_BASE_URL; + return raw.endsWith('/') ? raw.slice(0, -1) : raw; +} + +const API_BASE_URL = normalizeBaseUrl(); + +const ACCESS_TOKEN_KEY = 'access_token'; +const REFRESH_TOKEN_KEY = 'refresh_token'; +const USER_PROFILE_KEY = 'current_user'; + +let refreshInFlight: Promise | null = null; + +function readStoredAccessToken() { + return localStorage.getItem(ACCESS_TOKEN_KEY) ?? undefined; +} + +function readStoredRefreshToken() { + return localStorage.getItem(REFRESH_TOKEN_KEY) ?? undefined; +} + +function resolveAccessToken(accessToken?: string) { + return readStoredAccessToken() ?? accessToken; +} + +export function persistAuthSession(input: { accessToken: string; refreshToken: string; user?: UserProfile | null }) { + localStorage.setItem(ACCESS_TOKEN_KEY, input.accessToken); + localStorage.setItem(REFRESH_TOKEN_KEY, input.refreshToken); + if (input.user !== undefined) { + if (input.user) { + localStorage.setItem(USER_PROFILE_KEY, JSON.stringify(input.user)); + } else { + localStorage.removeItem(USER_PROFILE_KEY); + } + } +} + +export function clearPersistedAuthSession() { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(USER_PROFILE_KEY); +} + +async function refreshAccessTokenIfNeeded(): Promise { + if (refreshInFlight) { + return refreshInFlight; + } + + const refreshToken = readStoredRefreshToken(); + if (!refreshToken) { + throw new ApiError('unauthorized', 401); + } + + refreshInFlight = (async () => { + const response = await fetch(buildUrl('/auth/refresh'), { + method: 'POST', + headers: buildHeaders(undefined, undefined, true), + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + const text = await response.text(); + let payload: ApiEnvelope | null = null; + if (text) { + try { + payload = JSON.parse(text) as ApiEnvelope; + } catch (error) { + throw new ApiError(`Invalid JSON response from /auth/refresh: ${(error as Error).message}`, response.status, text); + } + } + + if (!response.ok || !payload || payload.code !== 200) { + clearPersistedAuthSession(); + throw new ApiError(payload?.msg ?? `HTTP ${response.status}`, response.status, payload ?? text); + } + + persistAuthSession({ + accessToken: payload.data.access_token, + refreshToken: payload.data.refresh_token, + }); + + return payload.data; + })(); + + try { + return await refreshInFlight; + } finally { + refreshInFlight = null; + } +} + +export class ApiError extends Error { + status: number; + payload?: unknown; + + constructor(message: string, status = 500, payload?: unknown) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.payload = payload; + } +} + +function buildUrl(path: string) { + if (/^https?:\/\//.test(path)) { + return path; + } + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${API_BASE_URL}${normalizedPath}`; +} + +function buildHeaders(initHeaders: HeadersInit | undefined, accessToken?: string, jsonBody = true) { + const headers = new Headers(initHeaders); + if (jsonBody && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + if (accessToken && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${accessToken}`); + } + return headers; +} + +async function request( + path: string, + init: RequestInit = {}, + accessToken?: string, + allowRefresh = true, +): Promise { + const isFormData = typeof FormData !== 'undefined' && init.body instanceof FormData; + const resolvedAccessToken = resolveAccessToken(accessToken); + const response = await fetch(buildUrl(path), { + ...init, + headers: buildHeaders(init.headers, resolvedAccessToken, init.body !== undefined && !isFormData), + }); + + const text = await response.text(); + let payload: ApiEnvelope | null = null; + + if (text) { + try { + payload = JSON.parse(text) as ApiEnvelope; + } catch (error) { + throw new ApiError( + `Invalid JSON response from ${path}: ${(error as Error).message}`, + response.status, + text, + ); + } + } + + if (response.status === 401 && allowRefresh && path !== '/auth/refresh') { + await refreshAccessTokenIfNeeded(); + return request(path, init, resolveAccessToken(), false); + } + + if (!response.ok) { + throw new ApiError(payload?.msg ?? `HTTP ${response.status}`, response.status, payload); + } + + if (!payload) { + throw new ApiError(`Empty response from ${path}`, response.status); + } + + if (payload.code !== 200) { + throw new ApiError(payload.msg || 'Business error', response.status, payload); + } + + return payload.data; +} + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function startGithubAuth(redirectUri: string): Promise { + return request('/auth/github/start', { + method: 'POST', + body: JSON.stringify({ redirect_uri: redirectUri }), + }); +} + +export async function completeGithubAuth(code: string, state: string): Promise { + return request('/auth/github/callback', { + method: 'POST', + body: JSON.stringify({ code, state }), + }); +} + +export async function refreshAuthToken(refreshToken: string): Promise { + return request('/auth/refresh', { + method: 'POST', + body: JSON.stringify({ refresh_token: refreshToken }), + }); +} + +export async function getCurrentUser(accessToken?: string): Promise { + return request('/auth/me', { method: 'GET' }, accessToken); +} + +export async function logout(refreshToken: string, accessToken?: string): Promise<{ success: boolean }> { + return request<{ success: boolean }>('/auth/logout', { + method: 'POST', + body: JSON.stringify({ refresh_token: refreshToken }), + }, accessToken); +} + +export async function createProject( + input: { name: string; template?: string }, + accessToken?: string, +): Promise { + return request('/projects', { + method: 'POST', + body: JSON.stringify({ + name: input.name, + template: input.template ?? 'blank', + }), + }, accessToken); +} + +export async function listProjects( + input: { + view?: 'all' | 'recent' | 'shared'; + keyword?: string; + status?: string; + sort_by?: string; + sort_order?: 'asc' | 'desc'; + page?: number; + page_size?: number; + } = {}, + accessToken?: string, +): Promise { + const params = new URLSearchParams(); + if (input.view) params.set('view', input.view); + if (input.keyword) params.set('keyword', input.keyword); + if (input.status) params.set('status', input.status); + if (input.sort_by) params.set('sort_by', input.sort_by); + if (input.sort_order) params.set('sort_order', input.sort_order); + if (input.page) params.set('page', String(input.page)); + if (input.page_size) params.set('page_size', String(input.page_size)); + + const query = params.toString(); + return request(`/projects${query ? `?${query}` : ''}`, { method: 'GET' }, accessToken); +} + +export async function getProject(projectId: string, accessToken?: string): Promise { + return request(`/projects/${encodeURIComponent(projectId)}`, { method: 'GET' }, accessToken); +} + +export async function updateProject( + projectId: string, + input: { name?: string; metadata?: { last_view_mode?: 'preview' | 'code' } }, + accessToken?: string, +): Promise { + return request(`/projects/${encodeURIComponent(projectId)}`, { + method: 'PATCH', + body: JSON.stringify(input), + }, accessToken); +} + +export async function deleteProject(projectId: string, accessToken?: string): Promise<{ success: boolean }> { + return request<{ success: boolean }>(`/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' }, accessToken); +} + +export async function getProjectUsage(accessToken?: string): Promise { + return request('/projects/usage', { method: 'GET' }, accessToken); +} + +export async function createGeneration( + projectId: string, + prompt: string, + attachments: GenerationAttachment[] = [], + accessToken?: string, + deep = false, +): Promise { + return request(`/projects/${encodeURIComponent(projectId)}/generations`, { + method: 'POST', + body: JSON.stringify({ + prompt, + attachments, + deep, + }), + }, accessToken); +} + +export async function getJob(jobId: string, accessToken?: string): Promise { + return request(`/jobs/${encodeURIComponent(jobId)}`, { method: 'GET' }, accessToken); +} + +export async function getChatMessages( + projectId: string, + cursor = '', + accessToken?: string, +): Promise { + const params = new URLSearchParams({ + cursor, + }); + return request( + `/projects/${encodeURIComponent(projectId)}/chat/messages?${params.toString()}`, + { method: 'GET' }, + accessToken, + ); +} + +export async function sendChatMessage( + projectId: string, + content: string, + accessToken?: string, + options: SendChatMessageOptions = {}, + allowRefresh = true, +): Promise { + const response = await fetch(buildUrl(`/projects/${encodeURIComponent(projectId)}/chat/messages`), { + method: 'POST', + headers: buildHeaders( + { + Accept: 'text/event-stream', + }, + resolveAccessToken(accessToken), + true, + ), + body: JSON.stringify({ + content, + attachments: [], + deep: options.deep ?? false, + }), + }); + + if (response.status === 401 && allowRefresh) { + await refreshAccessTokenIfNeeded(); + return sendChatMessage(projectId, content, resolveAccessToken(), options, false); + } + + if (!response.ok) { + const text = await response.text(); + const contentType = response.headers.get('Content-Type') ?? ''; + + if (contentType.includes('application/json') && text) { + try { + const payload = JSON.parse(text) as ApiEnvelope; + throw new ApiError(payload.msg ?? `HTTP ${response.status}`, response.status, payload); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + } + } + + throw new ApiError(text || `HTTP ${response.status}`, response.status, text); + } + + if (!response.body) { + throw new ApiError('Missing SSE response body.', response.status); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let assistantText = ''; + let messageId = ''; + let changes: Array<{ path: string; action: string }> = []; + + const processEvent = (rawEvent: string) => { + const data = rawEvent + .split('\n') + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(5).trim()) + .join('\n'); + + if (!data) { + return; + } + + const payload = JSON.parse(data) as ChatDeltaEvent | ChatDoneEvent; + if (payload.code !== 200) { + throw new ApiError(payload.msg || 'Business error', response.status, payload); + } + + if (payload.msg === 'delta') { + const deltaText = (payload.data as { text?: string }).text ?? ''; + if (!deltaText) { + return; + } + assistantText += deltaText; + options.onDelta?.(assistantText, deltaText); + return; + } + + if (payload.msg === 'done') { + const doneData = payload.data as { message_id?: string; changes?: Array<{ path: string; action: string }> }; + messageId = doneData.message_id ?? messageId; + changes = doneData.changes ?? changes; + return; + } + + if (payload.msg === 'error') { + throw new ApiError('Chat stream failed.', response.status, payload); + } + }; + + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done }).replace(/\r/g, ''); + + let boundaryIndex = buffer.indexOf('\n\n'); + while (boundaryIndex !== -1) { + const eventBlock = buffer.slice(0, boundaryIndex).trim(); + buffer = buffer.slice(boundaryIndex + 2); + if (eventBlock) { + processEvent(eventBlock); + } + boundaryIndex = buffer.indexOf('\n\n'); + } + + if (done) { + break; + } + } + + const tail = buffer.trim(); + if (tail) { + processEvent(tail); + } + + return { + assistant_message: { + id: messageId || `m_assistant_${Date.now()}`, + role: 'assistant', + content: assistantText, + created_at: new Date().toISOString(), + }, + changes, + }; +} + +export async function getFileTree( + projectId: string, + path = '/workspace', + depth = 3, + accessToken?: string, +): Promise { + const params = new URLSearchParams({ + path, + depth: String(depth), + }); + return request( + `/projects/${encodeURIComponent(projectId)}/files/tree?${params.toString()}`, + { method: 'GET' }, + accessToken, + ); +} + +export async function getFileContent( + projectId: string, + path: string, + accessToken?: string, +): Promise { + const params = new URLSearchParams({ path }); + return request( + `/projects/${encodeURIComponent(projectId)}/files/content?${params.toString()}`, + { method: 'GET' }, + accessToken, + ); +} + +function parseDownloadFileName(contentDisposition: string | null, fallbackFileName: string) { + if (!contentDisposition) { + return fallbackFileName; + } + + const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]); + } catch { + return utf8Match[1]; + } + } + + const plainMatch = contentDisposition.match(/filename\s*=\s*"?([^";]+)"?/i); + if (plainMatch?.[1]) { + return plainMatch[1]; + } + + return fallbackFileName; +} + +function triggerDownload(blob: Blob, fileName: string) { + const downloadUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = downloadUrl; + anchor.download = fileName; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 60_000); +} + +async function download(path: string, init: RequestInit = {}, accessToken?: string, fallbackFileName = 'download.bin') { + const response = await fetch(buildUrl(path), { + ...init, + headers: buildHeaders(init.headers, accessToken, false), + }); + + if (!response.ok) { + const text = await response.text(); + const contentType = response.headers.get('Content-Type') ?? ''; + + if (contentType.includes('application/json') && text) { + try { + const payload = JSON.parse(text) as ApiEnvelope; + throw new ApiError(payload.msg ?? `HTTP ${response.status}`, response.status, payload); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + } + } + + throw new ApiError(text || `HTTP ${response.status}`, response.status, text); + } + + const blob = await response.blob(); + const fileName = parseDownloadFileName(response.headers.get('Content-Disposition'), fallbackFileName); + triggerDownload(blob, fileName); + return { file_name: fileName } satisfies FileDownloadResult; +} + +export async function downloadProject( + projectId: string, + projectName?: string, + accessToken?: string, +): Promise { + const archiveBaseName = (projectName ?? projectId) + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') || projectId; + + return download( + `/projects/${encodeURIComponent(projectId)}/download`, + { method: 'GET' }, + accessToken, + `${archiveBaseName}.zip`, + ); +} + +export async function uploadImageAttachment(file: File, accessToken?: string): Promise { + if (!file.type.startsWith('image/')) { + throw new ApiError('Only image attachments are supported.', 400); + } + + const formData = new FormData(); + formData.append('file', file); + formData.append('purpose', 'generation'); + + return request('/files', { + method: 'POST', + body: formData, + }, accessToken); +} + +export async function getFileMeta(fileId: string, accessToken?: string): Promise { + return request(`/files/${encodeURIComponent(fileId)}`, { method: 'GET' }, accessToken); +} + +export async function startPreview(projectId: string, accessToken?: string): Promise { + return request(`/projects/${encodeURIComponent(projectId)}/preview/start`, { + method: 'POST', + body: JSON.stringify({ + device: 'desktop', + port: 3000, + }), + }, accessToken); +} + +export async function getPreview(projectId: string, accessToken?: string): Promise { + return request(`/projects/${encodeURIComponent(projectId)}/preview`, { + method: 'GET', + }, accessToken); +} diff --git a/app/fe/src/components/CodeEditor.tsx b/app/fe/src/components/CodeEditor.tsx new file mode 100644 index 0000000..298102a --- /dev/null +++ b/app/fe/src/components/CodeEditor.tsx @@ -0,0 +1,446 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import type React from 'react'; +import Editor from '@monaco-editor/react'; +import { + ChevronRight, + ChevronDown, + Folder, + FolderOpen, + File, + FileJson, + FileCode2, + Download, + X, + Loader2, + AlertCircle, +} from 'lucide-react'; +import { useI18n } from '../i18n'; +import type { FileContentResult, FileDownloadResult, FileTreeNode } from '../api'; + +type OpenFileTab = { + path: string; + name: string; + language?: string; + content?: string; + loading: boolean; + error?: string; +}; + +type CodeEditorProps = { + tree: FileTreeNode[]; + loading: boolean; + error: string | null; + refreshSignal?: number; + onOpenFile: (path: string) => Promise; + onDownloadProject: () => Promise; +}; + +type TreeIndex = { + paths: Set; + folderPaths: Set; +}; + +function filename(path: string) { + const parts = path.split('/').filter(Boolean); + return parts.length ? parts[parts.length - 1] : path; +} + +function guessLanguage(path: string) { + if (path.endsWith('.tsx') || path.endsWith('.ts')) return 'typescript'; + if (path.endsWith('.jsx') || path.endsWith('.js')) return 'javascript'; + if (path.endsWith('.json')) return 'json'; + if (path.endsWith('.css')) return 'css'; + if (path.endsWith('.md')) return 'markdown'; + if (path.endsWith('.html')) return 'html'; + return 'plaintext'; +} + +function getDefaultExpanded(nodes: FileTreeNode[]) { + const paths: string[] = []; + const walk = (items: FileTreeNode[], depth: number) => { + items.forEach((node) => { + if (node.type === 'folder' && depth <= 1) { + paths.push(node.path); + if (node.children) { + walk(node.children, depth + 1); + } + } + }); + }; + walk(nodes, 0); + return new Set(paths); +} + +function buildTreeIndex(nodes: FileTreeNode[]): TreeIndex { + const paths = new Set(); + const folderPaths = new Set(); + + const walk = (items: FileTreeNode[]) => { + items.forEach((node) => { + paths.add(node.path); + if (node.type === 'folder') { + folderPaths.add(node.path); + if (node.children) { + walk(node.children); + } + } + }); + }; + + walk(nodes); + return { paths, folderPaths }; +} + +const CodeEditor = memo(function CodeEditor({ + tree, + loading, + error, + refreshSignal = 0, + onOpenFile, + onDownloadProject, +}: CodeEditorProps) { + const { t } = useI18n(); + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [openFiles, setOpenFiles] = useState([]); + const [activeFilePath, setActiveFilePath] = useState(''); + const [downloadState, setDownloadState] = useState<{ loading: boolean; message: string | null; error: string | null }>({ + loading: false, + message: null, + error: null, + }); + + const treeIndex = useMemo(() => buildTreeIndex(tree), [tree]); + + useEffect(() => { + const defaultExpanded = getDefaultExpanded(tree); + + setExpandedFolders((previous) => { + const next = new Set(); + previous.forEach((path) => { + if (treeIndex.folderPaths.has(path)) { + next.add(path); + } + }); + defaultExpanded.forEach((path) => { + if (treeIndex.folderPaths.has(path)) { + next.add(path); + } + }); + return next; + }); + + setOpenFiles((previous) => previous.filter((file) => treeIndex.paths.has(file.path))); + }, [tree, treeIndex]); + + useEffect(() => { + setActiveFilePath((previous) => { + if (previous && openFiles.some((file) => file.path === previous)) { + return previous; + } + return openFiles.length ? openFiles[openFiles.length - 1].path : ''; + }); + }, [openFiles]); + + const activeFile = useMemo( + () => openFiles.find((file) => file.path === activeFilePath), + [openFiles, activeFilePath], + ); + + const loadFile = useCallback(async ( + path: string, + options?: { + activate?: boolean; + forceReload?: boolean; + silent?: boolean; + }, + ) => { + const activate = options?.activate ?? true; + const forceReload = options?.forceReload ?? false; + const silent = options?.silent ?? false; + const name = filename(path); + let shouldFetch = forceReload; + + if (activate) { + setActiveFilePath(path); + } + + setOpenFiles((previous) => { + const existing = previous.find((file) => file.path === path); + + if (!existing) { + shouldFetch = true; + return [ + ...previous, + { + path, + name, + loading: !silent, + error: undefined, + }, + ]; + } + + if (!forceReload && !existing.loading && (existing.content !== undefined || existing.error)) { + return previous; + } + + shouldFetch = true; + return previous.map((file) => { + if (file.path !== path) return file; + return { + ...file, + loading: silent ? file.loading : true, + error: undefined, + }; + }); + }); + + if (!shouldFetch) { + return; + } + + try { + const content = await onOpenFile(path); + setOpenFiles((previous) => { + const existing = previous.find((file) => file.path === path); + const nextFile: OpenFileTab = { + path, + name: existing?.name || name, + loading: false, + error: undefined, + language: content.language ?? guessLanguage(path), + content: content.content, + }; + + if (!existing) { + return [...previous, nextFile]; + } + + return previous.map((file) => (file.path === path ? nextFile : file)); + }); + } catch (openError) { + const message = (openError as Error).message; + setOpenFiles((previous) => + previous.map((file) => { + if (file.path !== path) return file; + if (silent && file.content !== undefined) { + return { + ...file, + loading: false, + }; + } + return { + ...file, + loading: false, + error: message, + }; + }), + ); + } + }, [onOpenFile]); + + useEffect(() => { + if (!refreshSignal || !activeFilePath) { + return; + } + + void loadFile(activeFilePath, { activate: false, forceReload: true, silent: true }); + }, [activeFilePath, loadFile, refreshSignal]); + + const toggleFolder = (path: string) => { + setExpandedFolders((previous) => { + const next = new Set(previous); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const openFile = async (node: FileTreeNode) => { + if (node.type !== 'file') { + return; + } + + await loadFile(node.path, { activate: true, forceReload: false, silent: false }); + }; + + const closeFile = (event: React.MouseEvent, path: string) => { + event.stopPropagation(); + setOpenFiles((previous) => previous.filter((file) => file.path !== path)); + }; + + const downloadProjectArchive = async () => { + if (downloadState.loading) { + return; + } + + setDownloadState({ loading: true, message: null, error: null }); + try { + const result = await onDownloadProject(); + const fileName = result.file_name || 'project.zip'; + setDownloadState({ loading: false, message: t('editor.downloaded', { fileName }), error: null }); + } catch (downloadError) { + setDownloadState({ loading: false, message: null, error: (downloadError as Error).message }); + } + }; + + const renderTree = (nodes: FileTreeNode[], depth = 0) => { + return nodes.map((node) => { + const isExpanded = expandedFolders.has(node.path); + const isActive = activeFilePath === node.path; + + if (node.type === 'folder') { + return ( +
+
toggleFolder(node.path)} + > + {isExpanded ? : } + {isExpanded ? ( + + ) : ( + + )} + {node.name} +
+ {isExpanded && node.children && renderTree(node.children, depth + 1)} +
+ ); + } + + return ( +
void openFile(node)} + > + {node.name.endsWith('.json') ? ( + + ) : node.name.endsWith('.tsx') || node.name.endsWith('.ts') ? ( + + ) : ( + + )} + {node.name} +
+ ); + }); + }; + + return ( +
+
+
+ {t('editor.explorer')} +
+
+ {loading ? ( +
+ {t('editor.loadingFiles')} +
+ ) : error ? ( +
+ {error} +
+ ) : tree.length === 0 ? ( +
No files found.
+ ) : ( + renderTree(tree) + )} +
+
+ +
+
+
+ {openFiles.map((file) => ( +
{ + void loadFile(file.path, { activate: true, forceReload: true, silent: true }); + }} + className={`flex items-center gap-2 px-3 py-2 min-w-[140px] max-w-[260px] cursor-pointer border-r border-[#2b2b2b] group select-none + ${ + activeFilePath === file.path + ? 'bg-[#1e1e1e] text-white border-t border-t-blue-500' + : 'bg-[#2d2d2d] text-[#969696] hover:bg-[#2b2b2b]' + }`} + > + {file.name.endsWith('.json') ? ( + + ) : file.name.endsWith('.tsx') || file.name.endsWith('.ts') ? ( + + ) : ( + + )} + {file.name} + +
+ ))} +
+ +
+ +
+
+ +
+ {(downloadState.message || downloadState.error) && ( +
+ {downloadState.error || downloadState.message} +
+ )} + {!activeFile ? ( +
{t('editor.empty')}
+ ) : activeFile.loading && activeFile.content === undefined ? ( +
+ Loading {activeFile.name}... +
+ ) : activeFile.error ? ( +
+ {activeFile.error} +
+ ) : ( + + )} +
+
+
+ ); +}); + +export default CodeEditor; diff --git a/app/fe/src/components/Dashboard.tsx b/app/fe/src/components/Dashboard.tsx new file mode 100644 index 0000000..0e858ad --- /dev/null +++ b/app/fe/src/components/Dashboard.tsx @@ -0,0 +1,263 @@ +import { useRef, useState, type ChangeEvent } from 'react'; +import { motion } from 'motion/react'; +import { + HelpCircle, + Sparkles, + Settings, + Zap, + Folder, + Loader2, + AlertCircle, + ImagePlus, + X, +} from 'lucide-react'; +import { useI18n } from '../i18n'; +import LanguageSwitcher from './LanguageSwitcher'; +import DeepToggle from './DeepToggle'; +import UserMenu from './UserMenu'; +import type { FileUploadResult, GenerationAttachment, UserProfile } from '../api'; + +type DashboardProps = { + onLogout: () => Promise | void; + onGenerate: (prompt: string, attachments: GenerationAttachment[]) => Promise | void; + onProjects: () => void; + onUploadImage: (file: File) => Promise; + deepEnabled: boolean; + onDeepEnabledChange: (next: boolean) => void; + isGenerating: boolean; + generationError: string | null; + currentUser: UserProfile | null; +}; + +type UploadedImage = { + file_id: string; + name: string; + mime_type: string; +}; + +export default function Dashboard({ + onLogout, + onGenerate, + onProjects, + onUploadImage, + deepEnabled, + onDeepEnabledChange, + isGenerating, + generationError, + currentUser, +}: DashboardProps) { + const { t } = useI18n(); + const [prompt, setPrompt] = useState(''); + const [attachments, setAttachments] = useState([]); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const fileInputRef = useRef(null); + + const handleGenerateClick = async () => { + const normalizedPrompt = prompt.trim(); + if (!normalizedPrompt || isGenerating) { + return; + } + await onGenerate( + normalizedPrompt, + attachments.map((attachment) => ({ file_id: attachment.file_id, name: attachment.name })), + ); + }; + + const handlePickImage = () => { + if (uploading || isGenerating) { + return; + } + fileInputRef.current?.click(); + }; + + const handleImageChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) { + return; + } + + setUploadError(null); + setUploading(true); + + try { + const uploaded = await onUploadImage(file); + setAttachments((previous) => [ + ...previous, + { + file_id: uploaded.file_id, + name: uploaded.name, + mime_type: uploaded.mime_type, + }, + ]); + } catch (error) { + setUploadError((error as Error).message || 'Failed to upload image.'); + } finally { + setUploading(false); + } + }; + + const removeAttachment = (fileId: string) => { + setAttachments((previous) => previous.filter((item) => item.file_id !== fileId)); + }; + + return ( + +
+
+
+ + + +
+ Agentland +
+
+ + + + +
+
+ +
+
+

{t('dashboard.title')}

+

{t('dashboard.subtitle')}

+
+ +
+
+
+ +
+