Compare commits

...

1 Commits

Author SHA1 Message Date
openhands 218c9fbc41 Add Status tab with Intent, Definition of Done, and Current Status fields 2025-05-17 22:30:21 +00:00
9 changed files with 489 additions and 4 deletions
+10 -1
View File
@@ -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"}`}
>
+5
View File
@@ -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",
+80
View File
@@ -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": "シークレット値は必須です",
+2 -1
View File
@@ -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"),
+7 -2
View File
@@ -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" />,
},
{
+161
View File
@@ -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>
);
}
+3
View File
@@ -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,
+2
View File
@@ -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)
+219
View File
@@ -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}'},
)