Merge branch 'main' into enhance/add-login-close-btn

This commit is contained in:
Puzhen Zhang 2025-11-06 09:54:26 +01:00 committed by GitHub
commit 0ddf559ec2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 121 additions and 10 deletions

View file

@ -0,0 +1,16 @@
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter(tags=["Health"])
class HealthResponse(BaseModel):
status: str
service: str
@router.get("/health", name="health check", response_model=HealthResponse)
async def health_check():
"""Health check endpoint for verifying backend is ready to accept requests."""
return HealthResponse(status="ok", service="eigent")

View file

@ -3,7 +3,7 @@ Centralized router registration for the Eigent API.
All routers are explicitly registered here for better visibility and maintainability.
"""
from fastapi import FastAPI
from app.controller import chat_controller, model_controller, task_controller, tool_controller
from app.controller import chat_controller, model_controller, task_controller, tool_controller, health_controller
from utils import traceroot_wrapper as traceroot
logger = traceroot.get_logger("router")
@ -23,6 +23,11 @@ def register_routers(app: FastAPI, prefix: str = "") -> None:
prefix: Optional global prefix for all routes (e.g., "/api")
"""
routers_config = [
{
"router": health_controller.router,
"tags": ["Health"],
"description": "Health check endpoint for service readiness"
},
{
"router": chat_controller.router,
"tags": ["chat"],

View file

@ -4,6 +4,7 @@ import log from 'electron-log'
import fs from 'fs'
import path from 'path'
import * as net from "net";
import * as http from "http";
import { ipcMain, BrowserWindow, app } from 'electron'
import { promisify } from 'util'
import { detectInstallationLogs, PromiseReturnType } from "./install-deps";
@ -195,21 +196,77 @@ export async function startBackend(setPort?: (port: number) => void): Promise<an
let started = false;
let healthCheckInterval: NodeJS.Timeout | null = null;
const startTimeout = setTimeout(() => {
if (!started) {
if (healthCheckInterval) clearInterval(healthCheckInterval);
node_process.kill();
reject(new Error('Backend failed to start within timeout'));
}
}, 30000); // 30 second timeout
// Helper function to poll health endpoint
const pollHealthEndpoint = (): void => {
let attempts = 0;
const maxAttempts = 20; // 5 seconds total (20 * 250ms)
const intervalMs = 250;
healthCheckInterval = setInterval(() => {
attempts++;
const healthUrl = `http://127.0.0.1:${port}/health`;
const req = http.get(healthUrl, { timeout: 1000 }, (res) => {
if (res.statusCode === 200) {
log.info(`Backend health check passed after ${attempts} attempts`);
started = true;
clearTimeout(startTimeout);
if (healthCheckInterval) clearInterval(healthCheckInterval);
resolve(node_process);
} else {
// Non-200 status (e.g., 404), continue polling unless max attempts reached
if (attempts >= maxAttempts) {
log.error(`Backend health check failed after ${attempts} attempts with status ${res.statusCode}`);
started = true;
clearTimeout(startTimeout);
if (healthCheckInterval) clearInterval(healthCheckInterval);
node_process.kill();
reject(new Error(`Backend health check failed: HTTP ${res.statusCode}`));
}
}
});
req.on('error', () => {
// Connection error - backend might not be ready yet, continue polling
if (attempts >= maxAttempts) {
log.error(`Backend health check failed after ${attempts} attempts: unable to connect`);
started = true;
clearTimeout(startTimeout);
if (healthCheckInterval) clearInterval(healthCheckInterval);
node_process.kill();
reject(new Error('Backend health check failed: unable to connect'));
}
});
req.on('timeout', () => {
req.destroy();
if (attempts >= maxAttempts) {
log.error(`Backend health check timed out after ${attempts} attempts`);
started = true;
clearTimeout(startTimeout);
if (healthCheckInterval) clearInterval(healthCheckInterval);
node_process.kill();
reject(new Error('Backend health check timed out'));
}
});
}, intervalMs);
};
node_process.stdout.on('data', (data) => {
displayFilteredLogs(data);
// check output content, judge if start success
if (!started && data.toString().includes("Uvicorn running on")) {
started = true;
clearTimeout(startTimeout);
resolve(node_process);
log.info('Uvicorn startup detected, starting health check polling...');
pollHealthEndpoint();
}
});
@ -217,9 +274,8 @@ export async function startBackend(setPort?: (port: number) => void): Promise<an
displayFilteredLogs(data);
if (!started && data.toString().includes("Uvicorn running on")) {
started = true;
clearTimeout(startTimeout);
resolve(node_process);
log.info('Uvicorn startup detected (stderr), starting health check polling...');
pollHealthEndpoint();
}
// Check for port binding errors
@ -227,6 +283,7 @@ export async function startBackend(setPort?: (port: number) => void): Promise<an
data.toString().includes("bind() failed")) {
started = true; // Prevent multiple rejections
clearTimeout(startTimeout);
if (healthCheckInterval) clearInterval(healthCheckInterval);
node_process.kill();
reject(new Error(`Port ${port} is already in use`));
}
@ -234,6 +291,7 @@ export async function startBackend(setPort?: (port: number) => void): Promise<an
node_process.on('close', (code) => {
clearTimeout(startTimeout);
if (healthCheckInterval) clearInterval(healthCheckInterval);
if (!started) {
reject(new Error(`fastapi exited with code ${code}`));
}

View file

@ -3,7 +3,7 @@ from fastapi_pagination import Page
from fastapi_pagination.ext.sqlmodel import paginate
from app.model.chat.chat_history import ChatHistoryOut, ChatHistoryIn, ChatHistory, ChatHistoryUpdate
from fastapi_babel import _
from sqlmodel import Session, select, desc
from sqlmodel import Session, select, desc, case
from app.component.auth import Auth, auth_must
from app.component.database import session
from utils import traceroot_wrapper as traceroot
@ -38,7 +38,19 @@ def create_chat_history(data: ChatHistoryIn, session: Session = Depends(session)
def list_chat_history(session: Session = Depends(session), auth: Auth = Depends(auth_must)) -> Page[ChatHistoryOut]:
"""List chat histories for current user."""
user_id = auth.user.id
stmt = select(ChatHistory).where(ChatHistory.user_id == user_id).order_by(desc(ChatHistory.created_at))
# Order by created_at descending, but fallback to id descending for old records without timestamps
# This ensures newer records with timestamps come first, followed by old records ordered by id
stmt = (
select(ChatHistory)
.where(ChatHistory.user_id == user_id)
.order_by(
desc(case((ChatHistory.created_at.is_(None), 0), else_=1)), # Non-null created_at first
desc(ChatHistory.created_at), # Then by created_at descending
desc(ChatHistory.id) # Finally by id descending for records with same/null created_at
)
)
result = paginate(session, stmt)
total = result.total if hasattr(result, 'total') else 0
logger.debug("Chat histories listed", extra={"user_id": user_id, "total": total})

View file

@ -2,6 +2,7 @@ from sqlalchemy import Float, Integer
from sqlmodel import Field, SmallInteger, Column, JSON, String
from typing import Optional
from enum import IntEnum
from datetime import datetime
from sqlalchemy_utils import ChoiceType
from app.model.abstract.model import AbstractModel, DefaultTimes
from pydantic import BaseModel, model_validator
@ -13,6 +14,16 @@ class ChatStatus(IntEnum):
class ChatHistory(AbstractModel, DefaultTimes, table=True):
"""
Chat history model with timestamp tracking.
Inherits from DefaultTimes which provides:
- created_at: timestamp when record is created (auto-populated)
- updated_at: timestamp when record is last modified (auto-updated)
- deleted_at: timestamp for soft deletion (nullable)
For legacy records without timestamps, sorting falls back to id ordering.
"""
id: int = Field(default=None, primary_key=True)
user_id: int = Field(index=True)
task_id: str = Field(index=True, unique=True)
@ -70,13 +81,22 @@ class ChatHistoryOut(BaseModel):
summary: str | None = None
tokens: int
status: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@model_validator(mode="after")
def fill_project_id_from_task_id(self):
"""fill by task_id when project_id is None"""
"""Fill project_id from task_id when project_id is None"""
if self.project_id is None:
self.project_id = self.task_id
return self
@model_validator(mode="after")
def handle_legacy_timestamps(self):
"""Handle legacy records that might not have timestamp fields"""
# For old records without timestamps, we rely on database-level defaults
# The sorting in the controller will handle ordering appropriately
return self
class ChatHistoryUpdate(BaseModel):