first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,16 @@
"""
Web Interface Routers Package
Contains all API router modules.
"""
from .conversation_router import router as conversation_router
from .agent_router import router as agent_router
from .chat_router import router as chat_router
from .health_router import router as health_router
__all__ = [
"conversation_router",
"agent_router",
"chat_router",
"health_router"
]
@@ -0,0 +1,65 @@
"""
Agent-related router module.
"""
import time
from typing import Dict, Any
from fastapi import APIRouter, Depends
from ..models import ModelListResponse, ModelInfo
from ..state import get_server_state, ServerState
from agent import list_available_models
router = APIRouter(prefix="/v1", tags=["agents"])
@router.get(
"/agents",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="List all active Agent instances for debugging and monitoring",
)
async def list_agents(state: ServerState = Depends(get_server_state)):
"""List all active Agent instances for debugging and monitoring."""
if not state.agent_registry:
return {"error": "Agent registry not initialized"}
agents_info = {}
for model_name in state.agent_registry.list_agents():
agent_info = state.agent_registry.get_agent_info(model_name)
if agent_info:
# Add instance ID for singleton verification.
agent = state.agent_registry.get_agent(model_name)
agent_info["instance_id"] = id(agent) if agent else None
agents_info[model_name] = agent_info
return {
"registry_stats": state.agent_registry.get_agent_stats(),
"agents": agents_info,
}
@router.get(
"/models",
response_model=ModelListResponse,
dependencies=[Depends(get_server_state)],
description="List all available models for model selection",
)
async def list_models():
"""List available models."""
available_models = list_available_models()
models = []
for model_name in available_models:
model_info = ModelInfo(
id=model_name,
object="model",
created=int(time.time()),
owned_by="simpleagent",
permission=None,
root=None,
parent=None,
)
models.append(model_info)
return ModelListResponse(object="list", data=models)
@@ -0,0 +1,327 @@
"""
Chat router module.
"""
import time
import json
from typing import AsyncGenerator, Any
from SimpleLLMFunc import push_error
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import StreamingResponse, JSONResponse
from ..models import (
ChatCompletionRequest,
)
from context.conversation_manager import Conversation
from ..state import get_server_state, ServerState
from ..utils import (
validate_chat_request,
get_or_create_conversation,
get_agent_for_model,
process_agent_response,
create_chat_response,
persist_request_images,
)
from ..error_handlers import create_error_response
from SimpleLLMFunc.logger import (
app_log,
push_warning,
log_context,
get_location,
)
from agent import BaseAgent
from observability import propagate_conversation_session
from react_stream import (
event_name_for_output,
format_sse,
is_response_yield,
project_response_to_oai_chunk,
serialize_react_output,
)
router = APIRouter(prefix="/v1/chat", tags=["chat"])
async def _persist_conversation(conversation: Conversation) -> None:
try:
await conversation.context.persist()
conversation.sketch_pad.persist()
app_log(
f"✅ Auto-saved conversation {conversation.uuid} after stream completion"
)
except Exception as save_error:
push_warning(
f"⚠️ Warning: Failed to save conversation {conversation.uuid}: {save_error}"
)
async def stream_chat_completion(
request: ChatCompletionRequest,
request_id: str,
conversation: Conversation,
agent: BaseAgent,
) -> AsyncGenerator[str, None]:
"""OpenAI-compatible streaming projection built from ReactOutput."""
query, _, raw_user_content = validate_chat_request(request)
app_log(
f"🔍 Starting stream chat completion for conversation {conversation.uuid}, the query is {query}, agent is {agent.name}"
)
created_time = int(time.time())
try:
with propagate_conversation_session(
conversation_id=conversation.uuid,
metadata={
"model": request.model,
"agent_name": agent.name,
"request_id": request_id,
"transport": "oai_stream",
},
tags=["cadagent", "oai_stream"],
):
with conversation:
sent_role = False
async for output in agent.run(query, raw_user_content=raw_user_content):
if not is_response_yield(output):
continue
chunk_obj = project_response_to_oai_chunk(
output,
request_id=request_id,
model=request.model,
created_time=created_time,
sent_role=sent_role,
)
if chunk_obj is None:
continue
try:
json_str = json.dumps(chunk_obj, ensure_ascii=False)
except Exception as encode_err:
push_warning(f"Failed to encode projected chunk: {encode_err}")
json_str = json.dumps(
{"error": str(encode_err)}, ensure_ascii=False
)
first_delta = chunk_obj.get("choices", [{}])[0].get("delta", {})
if (
isinstance(first_delta, dict)
and first_delta.get("role") == "assistant"
):
sent_role = True
app_log(f"🔍 Forwarding projected chunk: {json_str}")
yield f"data: {json_str}\n\n"
await _persist_conversation(conversation)
except Exception as e:
err_obj: dict[str, Any] = {
"id": request_id,
"object": "chat.completion.chunk",
"created": created_time,
"model": request.model,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": f"Error: {str(e)}"},
"finish_reason": "stop",
}
],
}
err_json = json.dumps(err_obj, ensure_ascii=False)
push_error(f"🔍 Sending error chunk: {err_json}", location=get_location())
yield f"data: {err_json}\n\n"
yield "data: [DONE]\n\n"
return
# End signal.
yield "data: [DONE]\n\n"
async def stream_chat_events(
request: ChatCompletionRequest,
conversation: Conversation,
agent: BaseAgent,
) -> AsyncGenerator[str, None]:
"""Native SSE stream that exposes our React event protocol."""
query, request_id, raw_user_content = validate_chat_request(request)
try:
with propagate_conversation_session(
conversation_id=conversation.uuid,
metadata={
"model": request.model,
"agent_name": agent.name,
"request_id": request_id,
"transport": "event_stream",
},
tags=["cadagent", "event_stream"],
):
with conversation:
async for output in agent.run(query, raw_user_content=raw_user_content):
payload = serialize_react_output(output, delta_consumer="web")
yield format_sse(event_name_for_output(output), payload)
await _persist_conversation(conversation)
except Exception as e:
error_payload = {
"type": "error",
"message": str(e),
}
yield format_sse("error", error_payload)
yield format_sse("done", {"ok": False})
return
yield format_sse("done", {"ok": True})
@router.post(
"/completions",
dependencies=[Depends(get_server_state)],
description="Chat completion endpoint compatible with the OpenAI specification",
)
async def chat_completions(
request: ChatCompletionRequest,
http_request: Request,
state: ServerState = Depends(get_server_state),
):
"""Chat completion endpoint compatible with the OpenAI specification."""
if not state.agent_registry:
return create_error_response(
message="Agent registry not initialized",
error_type="server_error",
status_code=500,
)
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
# Get conversation ID from the custom header.
conversation_id = http_request.headers.get("X-Conversation-ID")
with log_context(conversation_id=conversation_id):
try:
# Get or create the conversation.
conversation, conversation_id = get_or_create_conversation(
conversation_id, state.conversation_manager
)
persist_request_images(request, conversation_id)
# Get Agent.
agent = get_agent_for_model(request.model, state.agent_registry)
# Validate the request and retrieve the necessary information.
query, request_id, raw_user_content = validate_chat_request(request)
app_log(
f"🔍 {request_id} request chat completion for conversation {conversation_id}"
)
# Streaming response.
if request.stream:
return StreamingResponse(
stream_chat_completion(request, request_id, conversation, agent),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Conversation-ID": conversation_id,
},
)
# Non-streaming response: return text plus token statistics.
(
full_response,
prompt_tokens,
completion_tokens,
) = await process_agent_response(
query,
conversation,
agent,
raw_user_content=raw_user_content,
)
response = create_chat_response(
request_id,
request.model,
full_response,
prompt_tokens,
completion_tokens,
)
return JSONResponse(
content=response.model_dump(),
headers={"X-Conversation-ID": conversation_id},
)
except HTTPException:
# Re-raise HTTPException so FastAPI can handle it.
raise
except Exception as e:
return create_error_response(
message=f"Internal server error: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.post(
"/events",
dependencies=[Depends(get_server_state)],
description="Chat event stream endpoint using native ReactEvent Stream SSE",
)
async def chat_events(
request: ChatCompletionRequest,
http_request: Request,
state: ServerState = Depends(get_server_state),
):
if not state.agent_registry:
return create_error_response(
message="Agent registry not initialized",
error_type="server_error",
status_code=500,
)
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
conversation_id = http_request.headers.get("X-Conversation-ID")
with log_context(conversation_id=conversation_id):
try:
conversation, conversation_id = get_or_create_conversation(
conversation_id, state.conversation_manager
)
persist_request_images(request, conversation_id)
agent = get_agent_for_model(request.model, state.agent_registry)
validate_chat_request(request)
return StreamingResponse(
stream_chat_events(request, conversation, agent),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*",
"X-Conversation-ID": conversation_id,
},
)
except HTTPException:
raise
except Exception as e:
return create_error_response(
message=f"Internal server error: {str(e)}",
error_type="server_error",
status_code=500,
)
@@ -0,0 +1,484 @@
"""
Conversation-related router module.
"""
from pathlib import Path
from typing import Dict, Any, Optional
from urllib.parse import quote
from fastapi import APIRouter, Depends
from fastapi.responses import FileResponse
# Removed unused imports.
from ..state import get_server_state, ServerState
from ..error_handlers import create_error_response
from ..artifacts import (
extract_latest_artifacts,
guess_content_type,
resolve_project_path,
)
from config.config import get_config
router = APIRouter(prefix="/v1/conversations", tags=["conversations"])
def _build_artifact_url(conversation_id: str, path: Path) -> str:
encoded_path = quote(str(path), safe="")
return f"/v1/conversations/{conversation_id}/artifacts/raw?path={encoded_path}"
def _read_text_artifact(path: Path) -> Optional[str]:
try:
return path.read_text(encoding="utf-8")
except Exception:
return None
def _serialize_artifact_file(
conversation_id: str,
path: Path,
include_content: bool = False,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"path": str(path),
"content_type": guess_content_type(path),
"url": _build_artifact_url(conversation_id, path),
}
if include_content:
payload["content"] = _read_text_artifact(path)
return payload
@router.get(
"",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="List all available conversations",
)
async def list_conversations(state: ServerState = Depends(get_server_state)):
"""List all available conversations."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversations = state.conversation_manager.list_conversations()
return {"conversations": conversations, "total_count": len(conversations)}
except Exception as e:
return create_error_response(
message=f"Failed to list conversations: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.post(
"",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Create a new conversation",
)
async def create_conversation(
state: ServerState = Depends(get_server_state),
):
"""Create a new conversation.
Args:
llm_interface: LLM interface name; if None, use the default interface
max_history_length: Maximum Context history length
"""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
llm_obj = get_config().CONTEXT_SUMMARY_INTERFACE
max_history_length = get_config().CONTEXT_MAX_HISTORY_LENGTH
conversation = state.conversation_manager.create_conversation(
llm_interface=llm_obj, max_history_length=max_history_length
)
return {
"conversation_id": conversation.uuid,
"created_at": conversation.created_at.isoformat(),
"last_accessed": conversation.last_accessed.isoformat(),
}
except Exception as e:
return create_error_response(
message=f"Failed to create conversation: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Get information for the specified conversation",
)
async def get_conversation(
conversation_id: str, state: ServerState = Depends(get_server_state)
):
"""Get information for the specified conversation."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
# Get conversation statistics.
with conversation:
try:
history_count = conversation.context.get_total_message_count()
sketch_stats = conversation.sketch_pad.get_statistics()
# Convert the SketchPadStatistics object to a dictionary.
sketch_stats_dict = sketch_stats.model_dump()
except Exception as e:
history_count = 0
sketch_stats_dict = {}
return {
"conversation_id": conversation.uuid,
"created_at": conversation.created_at.isoformat(),
"last_accessed": conversation.last_accessed.isoformat(),
"message_count": history_count,
"sketch_stats": sketch_stats_dict,
}
except Exception as e:
return create_error_response(
message=f"Failed to get conversation: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.delete(
"",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Delete all conversations",
)
async def delete_all_conversations(state: ServerState = Depends(get_server_state)):
"""Delete all conversations."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
deleted_ids = state.conversation_manager.delete_all_conversations()
return {
"deleted": True,
"deleted_count": len(deleted_ids),
"conversation_ids": deleted_ids,
}
except Exception as e:
return create_error_response(
message=f"Failed to delete all conversations: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.delete(
"/{conversation_id}",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Delete the specified conversation",
)
async def delete_conversation(
conversation_id: str, state: ServerState = Depends(get_server_state)
):
"""Delete the specified conversation."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
success = state.conversation_manager.delete_conversation(conversation_id)
if not success:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
return {"deleted": True, "conversation_id": conversation_id}
except Exception as e:
return create_error_response(
message=f"Failed to delete conversation: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}/history",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Get the conversation history for the specified conversation",
)
async def get_conversation_history(
conversation_id: str,
limit: Optional[int] = None,
state: ServerState = Depends(get_server_state),
):
"""Get the conversation history for the specified conversation."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
# Get conversation history.
with conversation:
try:
# Get the complete conversation history.
history = [
message_item.model_dump()
for message_item in conversation.context.retrieve_full_messages()
]
total_messages = len(history)
# If a limit is specified, return only the most recent messages.
if limit and limit > 0:
history = history[-limit:]
return {
"conversation_id": conversation_id,
"messages": history,
"total_messages": total_messages,
"has_more": total_messages > len(history) if limit else False,
}
except Exception as e:
return create_error_response(
message=f"Failed to access conversation history: {str(e)}",
error_type="server_error",
status_code=500,
)
except Exception as e:
return create_error_response(
message=f"Failed to get conversation history: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}/sketchpad",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Get the SketchPad content for the specified conversation",
)
async def get_conversation_sketchpad(
conversation_id: str, state: ServerState = Depends(get_server_state)
):
"""Get the SketchPad content for the specified conversation."""
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
# Get SketchPad content.
with conversation:
try:
# Get sketch items that include concrete content.
sketch_items = conversation.sketch_pad.list_items(include_value=True)
sketch_stats = conversation.sketch_pad.get_statistics()
return {
"conversation_id": conversation_id,
"sketch_items": sketch_items,
"statistics": sketch_stats,
"total_items": len(sketch_items),
}
except Exception as e:
return create_error_response(
message=f"Failed to access SketchPad: {str(e)}",
error_type="server_error",
status_code=500,
)
except Exception as e:
return create_error_response(
message=f"Failed to get SketchPad: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}/artifacts/latest",
response_model=Dict[str, Any],
dependencies=[Depends(get_server_state)],
description="Get the latest generated code and model preview information for the specified conversation",
)
async def get_latest_conversation_artifacts(
conversation_id: str,
state: ServerState = Depends(get_server_state),
):
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
with conversation:
history = [
message_item.model_dump()
for message_item in conversation.context.retrieve_full_messages()
]
artifacts = extract_latest_artifacts(history)
code_path = artifacts.get("code_path")
code_paths = artifacts.get("code_paths", [])
model_path = artifacts.get("model_path")
model_paths = artifacts.get("model_paths", [])
output_paths = artifacts.get("output_paths", [])
response: Dict[str, Any] = {
"conversation_id": conversation_id,
"code_file": None,
"code_files": [],
"model_file": None,
"model_files": [],
"output_files": [str(path) for path in output_paths],
}
if isinstance(code_path, Path) and code_path.is_file():
response["code_file"] = _serialize_artifact_file(
conversation_id,
code_path,
include_content=True,
)
response["code_files"] = [
_serialize_artifact_file(
conversation_id,
candidate_path,
include_content=True,
)
for candidate_path in code_paths
if isinstance(candidate_path, Path) and candidate_path.is_file()
]
if isinstance(model_path, Path) and model_path.is_file():
response["model_file"] = _serialize_artifact_file(
conversation_id,
model_path,
)
response["model_files"] = [
_serialize_artifact_file(conversation_id, candidate_path)
for candidate_path in model_paths
if isinstance(candidate_path, Path) and candidate_path.is_file()
]
return response
except Exception as e:
return create_error_response(
message=f"Failed to get latest artifacts: {str(e)}",
error_type="server_error",
status_code=500,
)
@router.get(
"/{conversation_id}/artifacts/raw",
dependencies=[Depends(get_server_state)],
description="Get the raw content of an artifact file for the specified conversation",
)
async def get_conversation_artifact_file(
conversation_id: str,
path: str,
state: ServerState = Depends(get_server_state),
):
if not state.conversation_manager:
return create_error_response(
message="Conversation manager not initialized",
error_type="server_error",
status_code=500,
)
try:
conversation = state.conversation_manager.get_conversation(conversation_id)
if conversation is None:
return create_error_response(
message=f"Conversation {conversation_id} not found",
error_type="not_found",
status_code=404,
)
resolved_path = resolve_project_path(path)
if resolved_path is None or not resolved_path.is_file():
return create_error_response(
message="Artifact file not found or outside project root",
error_type="not_found",
status_code=404,
)
return FileResponse(
path=str(resolved_path),
media_type=guess_content_type(resolved_path),
filename=resolved_path.name,
)
except Exception as e:
return create_error_response(
message=f"Failed to get artifact file: {str(e)}",
error_type="server_error",
status_code=500,
)
@@ -0,0 +1,46 @@
"""
Health check router module.
"""
from datetime import datetime
from fastapi import APIRouter, Depends
from typing import Dict, Any
from ..models import HealthResponse, ServerInfoResponse
from ..state import get_server_state, ServerState
router = APIRouter()
@router.get("/", response_model=ServerInfoResponse)
async def root():
"""Server root path information."""
return ServerInfoResponse(
name="CADDesigner API Server",
version="1.0.0",
description="OpenAI-compatible API for CADDesigner",
api_version="v1",
supported_models=["cadagent"],
capabilities=[
"chat.completions",
"streaming",
"tool_calling",
"conversation_history",
"sketch_pad_storage",
],
)
@router.get("/health", response_model=HealthResponse)
async def health_check(state: ServerState = Depends(get_server_state)):
"""Health check endpoint."""
default_agent = (
state.agent_registry.get_agent("cadagent") if state.agent_registry else None
)
return HealthResponse(
status="ok",
timestamp=datetime.now().isoformat(),
version="1.0.0",
agent_name=default_agent.name if default_agent else "Not initialized",
)