mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 218c9fbc41 |
@@ -3,6 +3,7 @@ import { useLocation } from "react-router";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
|
||||
// Lazy load all tab components
|
||||
const StatusTab = lazy(() => import("#/routes/status-tab"));
|
||||
const EditorTab = lazy(() => import("#/routes/changes-tab"));
|
||||
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
|
||||
const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
|
||||
@@ -19,7 +20,10 @@ export function TabContent({ conversationPath }: TabContentProps) {
|
||||
const currentPath = location.pathname;
|
||||
|
||||
// Determine which tab is active based on the current path
|
||||
const isEditorActive = currentPath === conversationPath;
|
||||
const isStatusActive =
|
||||
currentPath === `${conversationPath}/status` ||
|
||||
currentPath === conversationPath;
|
||||
const isEditorActive = currentPath === `${conversationPath}/changes`;
|
||||
const isBrowserActive = currentPath === `${conversationPath}/browser`;
|
||||
const isJupyterActive = currentPath === `${conversationPath}/jupyter`;
|
||||
const isServedActive = currentPath === `${conversationPath}/served`;
|
||||
@@ -36,6 +40,11 @@ export function TabContent({ conversationPath }: TabContentProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 ${isStatusActive ? "block" : "hidden"}`}
|
||||
>
|
||||
<StatusTab />
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 ${isEditorActive ? "block" : "hidden"}`}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
STATUS$TAB_LABEL = "STATUS$TAB_LABEL",
|
||||
STATUS$INTENT_TITLE = "STATUS$INTENT_TITLE",
|
||||
STATUS$DEFINITION_OF_DONE_TITLE = "STATUS$DEFINITION_OF_DONE_TITLE",
|
||||
STATUS$CURRENT_STATUS_TITLE = "STATUS$CURRENT_STATUS_TITLE",
|
||||
STATUS$ERROR_FETCHING = "STATUS$ERROR_FETCHING",
|
||||
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
|
||||
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
|
||||
SECRETS$EDIT_SECRET = "SECRETS$EDIT_SECRET",
|
||||
|
||||
@@ -1,4 +1,84 @@
|
||||
{
|
||||
"STATUS$TAB_LABEL": {
|
||||
"en": "Status",
|
||||
"ja": "ステータス",
|
||||
"zh-CN": "状态",
|
||||
"zh-TW": "狀態",
|
||||
"ko-KR": "상태",
|
||||
"no": "Status",
|
||||
"it": "Stato",
|
||||
"pt": "Estado",
|
||||
"es": "Estado",
|
||||
"ar": "الحالة",
|
||||
"fr": "Statut",
|
||||
"tr": "Durum",
|
||||
"de": "Status",
|
||||
"uk": "Статус"
|
||||
},
|
||||
"STATUS$INTENT_TITLE": {
|
||||
"en": "Intent",
|
||||
"ja": "意図",
|
||||
"zh-CN": "意图",
|
||||
"zh-TW": "意圖",
|
||||
"ko-KR": "의도",
|
||||
"no": "Hensikt",
|
||||
"it": "Intento",
|
||||
"pt": "Intenção",
|
||||
"es": "Intención",
|
||||
"ar": "النية",
|
||||
"fr": "Intention",
|
||||
"tr": "Niyet",
|
||||
"de": "Absicht",
|
||||
"uk": "Намір"
|
||||
},
|
||||
"STATUS$DEFINITION_OF_DONE_TITLE": {
|
||||
"en": "Definition of Done",
|
||||
"ja": "完了の定義",
|
||||
"zh-CN": "完成的定义",
|
||||
"zh-TW": "完成的定義",
|
||||
"ko-KR": "완료 정의",
|
||||
"no": "Definisjon av ferdig",
|
||||
"it": "Definizione di Fatto",
|
||||
"pt": "Definição de Concluído",
|
||||
"es": "Definición de Terminado",
|
||||
"ar": "تعريف الانتهاء",
|
||||
"fr": "Définition de Terminé",
|
||||
"tr": "Tamamlanma Tanımı",
|
||||
"de": "Definition of Done",
|
||||
"uk": "Визначення завершеності"
|
||||
},
|
||||
"STATUS$CURRENT_STATUS_TITLE": {
|
||||
"en": "Current Status",
|
||||
"ja": "現在の状況",
|
||||
"zh-CN": "当前状态",
|
||||
"zh-TW": "當前狀態",
|
||||
"ko-KR": "현재 상태",
|
||||
"no": "Nåværende status",
|
||||
"it": "Stato Attuale",
|
||||
"pt": "Estado Atual",
|
||||
"es": "Estado Actual",
|
||||
"ar": "الحالة الحالية",
|
||||
"fr": "Statut Actuel",
|
||||
"tr": "Mevcut Durum",
|
||||
"de": "Aktueller Status",
|
||||
"uk": "Поточний статус"
|
||||
},
|
||||
"STATUS$ERROR_FETCHING": {
|
||||
"en": "Error fetching status field:",
|
||||
"ja": "ステータスフィールドの取得エラー:",
|
||||
"zh-CN": "获取状态字段时出错:",
|
||||
"zh-TW": "獲取狀態字段時出錯:",
|
||||
"ko-KR": "상태 필드 가져오기 오류:",
|
||||
"no": "Feil ved henting av statusfelt:",
|
||||
"it": "Errore durante il recupero del campo di stato:",
|
||||
"pt": "Erro ao buscar campo de status:",
|
||||
"es": "Error al obtener el campo de estado:",
|
||||
"ar": "خطأ في جلب حقل الحالة:",
|
||||
"fr": "Erreur lors de la récupération du champ de statut:",
|
||||
"tr": "Durum alanı getirilirken hata oluştu:",
|
||||
"de": "Fehler beim Abrufen des Statusfelds:",
|
||||
"uk": "Помилка отримання поля статусу:"
|
||||
},
|
||||
"SECRETS$SECRET_VALUE_REQUIRED": {
|
||||
"en": "Secret value is required",
|
||||
"ja": "シークレット値は必須です",
|
||||
|
||||
@@ -19,7 +19,8 @@ export default [
|
||||
route("api-keys", "routes/api-keys.tsx"),
|
||||
]),
|
||||
route("conversations/:conversationId", "routes/conversation.tsx", [
|
||||
index("routes/changes-tab.tsx"),
|
||||
index("routes/status-tab.tsx"),
|
||||
route("changes", "routes/changes-tab.tsx"),
|
||||
route("browser", "routes/browser-tab.tsx"),
|
||||
route("jupyter", "routes/jupyter-tab.tsx"),
|
||||
route("served", "routes/served-tab.tsx"),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useDisclosure } from "@heroui/react";
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { FaServer, FaExternalLinkAlt } from "react-icons/fa";
|
||||
import { FaServer, FaExternalLinkAlt, FaClipboardList } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiGit } from "react-icons/di";
|
||||
import { VscCode } from "react-icons/vsc";
|
||||
@@ -133,9 +133,14 @@ function AppContent() {
|
||||
<Container
|
||||
className="h-full w-full"
|
||||
labels={[
|
||||
{
|
||||
label: t(I18nKey.STATUS$TAB_LABEL),
|
||||
to: "status",
|
||||
icon: <FaClipboardList className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
label: "Changes",
|
||||
to: "",
|
||||
to: "changes",
|
||||
icon: <DiGit className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
|
||||
interface StatusField {
|
||||
title: string;
|
||||
value: string;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
intent?: string;
|
||||
definition_of_done?: string;
|
||||
current_status?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function StatusTab() {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
|
||||
const [intent, setIntent] = useState<StatusField>({
|
||||
title: t(I18nKey.STATUS$INTENT_TITLE),
|
||||
value: "",
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [definitionOfDone, setDefinitionOfDone] = useState<StatusField>({
|
||||
title: t(I18nKey.STATUS$DEFINITION_OF_DONE_TITLE),
|
||||
value: "",
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [currentStatus, setCurrentStatus] = useState<StatusField>({
|
||||
title: t(I18nKey.STATUS$CURRENT_STATUS_TITLE),
|
||||
value: "",
|
||||
isLoading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchStatusField = async (
|
||||
url: string,
|
||||
onSuccess: (data: StatusResponse) => void,
|
||||
onError: (error: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
onSuccess(data);
|
||||
} catch (error) {
|
||||
// Log error but don't show console message in production
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(t(I18nKey.STATUS$ERROR_FETCHING), error);
|
||||
}
|
||||
onError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
|
||||
// Fetch intent
|
||||
fetchStatusField(
|
||||
`/api/conversations/${conversationId}/status/intent`,
|
||||
(data) =>
|
||||
setIntent({
|
||||
...intent,
|
||||
value: data.intent || "",
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
(error) =>
|
||||
setIntent({
|
||||
...intent,
|
||||
isLoading: false,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fetch definition of done
|
||||
fetchStatusField(
|
||||
`/api/conversations/${conversationId}/status/definition-of-done`,
|
||||
(data) =>
|
||||
setDefinitionOfDone({
|
||||
...definitionOfDone,
|
||||
value: data.definition_of_done || "",
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
(error) =>
|
||||
setDefinitionOfDone({
|
||||
...definitionOfDone,
|
||||
isLoading: false,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
|
||||
// Fetch current status
|
||||
fetchStatusField(
|
||||
`/api/conversations/${conversationId}/status/current-status`,
|
||||
(data) =>
|
||||
setCurrentStatus({
|
||||
...currentStatus,
|
||||
value: data.current_status || "",
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
(error) =>
|
||||
setCurrentStatus({
|
||||
...currentStatus,
|
||||
isLoading: false,
|
||||
error,
|
||||
}),
|
||||
);
|
||||
}, [conversationId, intent, definitionOfDone, currentStatus, t]);
|
||||
|
||||
const renderStatusField = (field: StatusField) => {
|
||||
let content;
|
||||
if (field.isLoading) {
|
||||
content = (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
);
|
||||
} else if (field.error) {
|
||||
content = <div className="text-red-400 text-sm">{field.error}</div>;
|
||||
} else {
|
||||
content = <div className="text-sm text-neutral-200">{field.value}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-neutral-300 mb-2">
|
||||
{field.title}
|
||||
</h3>
|
||||
<div className="bg-base-primary p-3 rounded-md border border-neutral-700">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto p-4 bg-base-secondary">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{renderStatusField(intent)}
|
||||
{renderStatusField(definitionOfDone)}
|
||||
{renderStatusField(currentStatus)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
enum TabOption {
|
||||
STATUS = "status",
|
||||
PLANNER = "planner",
|
||||
BROWSER = "browser",
|
||||
JUPYTER = "jupyter",
|
||||
@@ -6,12 +7,14 @@ enum TabOption {
|
||||
}
|
||||
|
||||
type TabType =
|
||||
| TabOption.STATUS
|
||||
| TabOption.PLANNER
|
||||
| TabOption.BROWSER
|
||||
| TabOption.JUPYTER
|
||||
| TabOption.VSCODE;
|
||||
|
||||
const AllTabs = [
|
||||
TabOption.STATUS,
|
||||
TabOption.VSCODE,
|
||||
TabOption.BROWSER,
|
||||
TabOption.PLANNER,
|
||||
|
||||
@@ -22,6 +22,7 @@ from openhands.server.routes.public import app as public_api_router
|
||||
from openhands.server.routes.secrets import app as secrets_router
|
||||
from openhands.server.routes.security import app as security_api_router
|
||||
from openhands.server.routes.settings import app as settings_router
|
||||
from openhands.server.routes.status import app as status_api_router
|
||||
from openhands.server.routes.trajectory import app as trajectory_router
|
||||
from openhands.server.shared import conversation_manager
|
||||
|
||||
@@ -54,4 +55,5 @@ app.include_router(manage_conversation_api_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(secrets_router)
|
||||
app.include_router(git_api_router)
|
||||
app.include_router(status_api_router)
|
||||
app.include_router(trajectory_router)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
"""API endpoints for conversation status information."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.server.user_auth import get_user_settings
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}/status')
|
||||
|
||||
|
||||
async def get_settings(request: Request) -> Settings:
|
||||
"""Get the settings for the current user."""
|
||||
settings = await get_user_settings(request)
|
||||
if not settings:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail='Settings not found'
|
||||
)
|
||||
return settings
|
||||
|
||||
|
||||
async def generate_status_field(events: list[Event], prompt: str, settings: Settings) -> str:
|
||||
"""Generate a status field using the LLM.
|
||||
|
||||
Args:
|
||||
events: List of events from the conversation
|
||||
prompt: The prompt to send to the LLM
|
||||
settings: User settings containing LLM configuration
|
||||
|
||||
Returns:
|
||||
A string containing the generated status field
|
||||
"""
|
||||
try:
|
||||
if not settings or not settings.llm_model:
|
||||
return "Unable to generate status: LLM not configured"
|
||||
|
||||
# Create LLM config from settings
|
||||
llm_config = LLMConfig(
|
||||
model=settings.llm_model,
|
||||
api_key=settings.llm_api_key,
|
||||
base_url=settings.llm_base_url,
|
||||
)
|
||||
|
||||
# Convert events to a format suitable for the LLM
|
||||
event_dicts = [event_to_dict(event) for event in events]
|
||||
conversation_text = "\n".join([
|
||||
f"{event.get('source', 'unknown')}: {event.get('content', '')}"
|
||||
for event in event_dicts
|
||||
if event.get('content')
|
||||
])
|
||||
|
||||
# Truncate if too long
|
||||
if len(conversation_text) > 10000:
|
||||
conversation_text = conversation_text[:10000] + "...(truncated)"
|
||||
|
||||
# Create a prompt for the LLM
|
||||
messages = [
|
||||
{
|
||||
'role': 'system',
|
||||
'content': prompt,
|
||||
},
|
||||
{
|
||||
'role': 'user',
|
||||
'content': f'Here is the conversation:\n\n{conversation_text}',
|
||||
},
|
||||
]
|
||||
|
||||
# Get response from LLM
|
||||
llm = LLM(llm_config)
|
||||
response = llm.completion(messages=messages)
|
||||
result = response.choices[0].message.content.strip()
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f'Error generating status field: {e}')
|
||||
return f"Error generating status: {str(e)}"
|
||||
|
||||
|
||||
@app.get('/intent')
|
||||
async def get_intent(request: Request, settings: Settings = Depends(get_settings)) -> JSONResponse:
|
||||
"""Get the intent of the conversation.
|
||||
|
||||
This endpoint analyzes the conversation and returns a concise statement of the user's intent.
|
||||
|
||||
Args:
|
||||
request: The incoming FastAPI request object
|
||||
settings: User settings containing LLM configuration
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response containing the intent
|
||||
"""
|
||||
try:
|
||||
if not request.state.conversation:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Conversation not found'}
|
||||
)
|
||||
|
||||
# Get all events from the conversation
|
||||
event_stream = request.state.conversation.event_stream
|
||||
events = list(event_stream.get_events())
|
||||
|
||||
# Generate the intent using the LLM
|
||||
prompt = """You are a helpful assistant that analyzes conversations between a user and OpenHands AI.
|
||||
Your task is to identify and summarize the user's primary intent or goal in this conversation.
|
||||
Provide a single, concise sentence (maximum 50 words) that clearly states what the user is trying to accomplish.
|
||||
Focus only on the user's main objective, not on intermediate steps or implementation details.
|
||||
Return only the intent statement, with no additional text, quotes, or explanations."""
|
||||
|
||||
intent = await generate_status_field(events, prompt, settings)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'intent': intent}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting intent: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': f'Error getting intent: {e}'},
|
||||
)
|
||||
|
||||
|
||||
@app.get('/definition-of-done')
|
||||
async def get_definition_of_done(request: Request, settings: Settings = Depends(get_settings)) -> JSONResponse:
|
||||
"""Get the definition of done for the conversation.
|
||||
|
||||
This endpoint analyzes the conversation and returns a concise statement of what would constitute
|
||||
a successful completion of the user's request.
|
||||
|
||||
Args:
|
||||
request: The incoming FastAPI request object
|
||||
settings: User settings containing LLM configuration
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response containing the definition of done
|
||||
"""
|
||||
try:
|
||||
if not request.state.conversation:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Conversation not found'}
|
||||
)
|
||||
|
||||
# Get all events from the conversation
|
||||
event_stream = request.state.conversation.event_stream
|
||||
events = list(event_stream.get_events())
|
||||
|
||||
# Generate the definition of done using the LLM
|
||||
prompt = """You are a helpful assistant that analyzes conversations between a user and OpenHands AI.
|
||||
Your task is to define what would constitute a successful completion of the user's request.
|
||||
Provide a single, concise sentence (maximum 50 words) that clearly states the criteria for considering the task complete.
|
||||
Focus on measurable outcomes and specific deliverables that would satisfy the user's request.
|
||||
Return only the definition of done statement, with no additional text, quotes, or explanations."""
|
||||
|
||||
definition_of_done = await generate_status_field(events, prompt, settings)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'definition_of_done': definition_of_done}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting definition of done: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': f'Error getting definition of done: {e}'},
|
||||
)
|
||||
|
||||
|
||||
@app.get('/current-status')
|
||||
async def get_current_status(request: Request, settings: Settings = Depends(get_settings)) -> JSONResponse:
|
||||
"""Get the current status of the conversation.
|
||||
|
||||
This endpoint analyzes the conversation and returns a concise statement of the current progress
|
||||
towards completing the user's request.
|
||||
|
||||
Args:
|
||||
request: The incoming FastAPI request object
|
||||
settings: User settings containing LLM configuration
|
||||
|
||||
Returns:
|
||||
JSONResponse: A JSON response containing the current status
|
||||
"""
|
||||
try:
|
||||
if not request.state.conversation:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'Conversation not found'}
|
||||
)
|
||||
|
||||
# Get all events from the conversation
|
||||
event_stream = request.state.conversation.event_stream
|
||||
events = list(event_stream.get_events())
|
||||
|
||||
# Generate the current status using the LLM
|
||||
prompt = """You are a helpful assistant that analyzes conversations between a user and OpenHands AI.
|
||||
Your task is to assess and summarize the current progress towards completing the user's request.
|
||||
Provide a single, concise sentence (maximum 50 words) that clearly states what has been accomplished so far
|
||||
and what remains to be done.
|
||||
Focus on the current state of the task, major milestones achieved, and any significant challenges encountered.
|
||||
Return only the current status statement, with no additional text, quotes, or explanations."""
|
||||
|
||||
current_status = await generate_status_field(events, prompt, settings)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'current_status': current_status}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error getting current status: {e}')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={'error': f'Error getting current status: {e}'},
|
||||
)
|
||||
Reference in New Issue
Block a user