Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions autogpt_platform/backend/backend/copilot/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ChatSessionWhereInput,
)

from backend.copilot.model import invalidate_session_cache
from backend.data import db
from backend.util.json import SafeJson, sanitize_string

Expand Down Expand Up @@ -217,6 +218,9 @@ async def add_chat_messages_batch(
if msg.get("function_call") is not None:
data["functionCall"] = SafeJson(msg["function_call"])

if msg.get("duration_ms") is not None:
data["durationMs"] = msg["duration_ms"]

messages_data.append(data)

# Run create_many and session update in parallel within transaction
Expand Down Expand Up @@ -359,3 +363,22 @@ async def update_tool_message_content(
f"tool_call_id {tool_call_id}: {e}"
)
Comment thread
0ubbe marked this conversation as resolved.
return False


async def set_turn_duration(session_id: str, duration_ms: int) -> None:
"""Set durationMs on the last assistant message in a session.

Also invalidates the Redis session cache so the next GET returns
the updated duration.
"""
last_msg = await PrismaChatMessage.prisma().find_first(
where={"sessionId": session_id, "role": "assistant"},
Comment thread
0ubbe marked this conversation as resolved.
order={"sequence": "desc"},
)
if last_msg:
await PrismaChatMessage.prisma().update(
where={"id": last_msg.id},
data={"durationMs": duration_ms},
)
# Invalidate cache so the session is re-fetched from DB with durationMs
await invalidate_session_cache(session_id)
2 changes: 2 additions & 0 deletions autogpt_platform/backend/backend/copilot/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class ChatMessage(BaseModel):
refusal: str | None = None
tool_calls: list[dict] | None = None
function_call: dict | None = None
duration_ms: int | None = None

Comment thread
0ubbe marked this conversation as resolved.
@staticmethod
def from_db(prisma_message: PrismaChatMessage) -> "ChatMessage":
Expand All @@ -66,6 +67,7 @@ def from_db(prisma_message: PrismaChatMessage) -> "ChatMessage":
refusal=prisma_message.refusal,
tool_calls=_parse_json_field(prisma_message.toolCalls),
function_call=_parse_json_field(prisma_message.functionCall),
duration_ms=prisma_message.durationMs,
)


Expand Down
38 changes: 38 additions & 0 deletions autogpt_platform/backend/backend/copilot/stream_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
AsyncRedisNotificationEventBus,
NotificationEvent,
)
from backend.data.db_accessors import chat_db
from backend.data.redis_client import get_redis_async

from .config import ChatConfig
Expand Down Expand Up @@ -111,6 +112,14 @@ def _parse_session_meta(meta: dict[Any, Any], session_id: str = "") -> ActiveSes
``session_id`` is used as a fallback for ``turn_id`` when the meta hash
pre-dates the turn_id field (backward compat for in-flight sessions).
"""
created_at = datetime.now(timezone.utc)
created_at_raw = meta.get("created_at")
if created_at_raw:
try:
created_at = datetime.fromisoformat(str(created_at_raw))
except (ValueError, TypeError):
pass
Comment thread
0ubbe marked this conversation as resolved.

return ActiveSession(
session_id=meta.get("session_id", "") or session_id,
user_id=meta.get("user_id", "") or None,
Expand All @@ -119,6 +128,7 @@ def _parse_session_meta(meta: dict[Any, Any], session_id: str = "") -> ActiveSes
turn_id=meta.get("turn_id", "") or session_id,
blocking=meta.get("blocking") == "1",
status=meta.get("status", "running"), # type: ignore[arg-type]
created_at=created_at,
)


Expand Down Expand Up @@ -802,6 +812,34 @@ async def mark_session_completed(
f"Failed to publish error event for session {session_id}: {e}"
)

# Compute wall-clock duration from session created_at.
# Only persist when (a) the session completed successfully and
# (b) created_at was actually present in Redis meta (not a fallback).
duration_ms: int | None = None
if meta and not error_message:
created_at_raw = meta.get("created_at")
if created_at_raw:
try:
created_at = datetime.fromisoformat(str(created_at_raw))
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
elapsed = datetime.now(timezone.utc) - created_at
duration_ms = max(0, int(elapsed.total_seconds() * 1000))
except (ValueError, TypeError):
logger.warning(
"Failed to compute session duration for %s "
"(created_at=%r)",
session_id,
created_at_raw,
)

# Persist duration on the last assistant message
if duration_ms is not None:
Comment thread
0ubbe marked this conversation as resolved.
try:
await chat_db().set_turn_duration(session_id, duration_ms)
Comment thread
0ubbe marked this conversation as resolved.
except Exception as e:
logger.warning(f"Failed to save turn duration for {session_id}: {e}")

# Publish StreamFinish AFTER status is set to "completed"/"failed".
# This is the SINGLE place that publishes StreamFinish — services and
# the processor must NOT publish it themselves.
Expand Down
2 changes: 2 additions & 0 deletions autogpt_platform/backend/backend/data/db_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ def _(
get_next_sequence = _(chat_db.get_next_sequence)
update_tool_message_content = _(chat_db.update_tool_message_content)
update_chat_session_title = _(chat_db.update_chat_session_title)
set_turn_duration = _(chat_db.set_turn_duration)


class DatabaseManagerClient(AppServiceClient):
Expand Down Expand Up @@ -540,3 +541,4 @@ def get_service_type(cls):
get_next_sequence = d.get_next_sequence
update_tool_message_content = d.update_tool_message_content
update_chat_session_title = d.update_chat_session_title
set_turn_duration = d.set_turn_duration
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add durationMs column to ChatMessage for persisting turn elapsed time.
ALTER TABLE "ChatMessage" ADD COLUMN "durationMs" INTEGER;
3 changes: 2 additions & 1 deletion autogpt_platform/backend/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,8 @@ model ChatMessage {
functionCall Json? // Deprecated but kept for compatibility

// Ordering within session
sequence Int
sequence Int
durationMs Int? // Wall-clock milliseconds for this assistant turn

@@unique([sessionId, sequence])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export function CopilotPage() {
isDeleting,
handleConfirmDelete,
handleCancelDelete,
// Historical durations for persisted timer stats
historicalDurations,
// Rate limit reset
rateLimitMessage,
dismissRateLimit,
Expand Down Expand Up @@ -186,6 +188,7 @@ export function CopilotPage() {
isUploadingFiles={isUploadingFiles}
droppedFiles={droppedFiles}
onDroppedFilesConsumed={handleDroppedFilesConsumed}
historicalDurations={historicalDurations}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface ChatContainerProps {
droppedFiles?: File[];
/** Called after droppedFiles have been consumed by ChatInput. */
onDroppedFilesConsumed?: () => void;
/** Duration in ms for historical turns, keyed by message ID. */
historicalDurations?: Map<string, number>;
}
export const ChatContainer = ({
messages,
Expand All @@ -44,6 +46,7 @@ export const ChatContainer = ({
isUploadingFiles,
droppedFiles,
onDroppedFilesConsumed,
historicalDurations,
}: ChatContainerProps) => {
const isBusy =
status === "streaming" ||
Expand Down Expand Up @@ -81,6 +84,7 @@ export const ChatContainer = ({
isLoading={isLoadingSession}
sessionID={sessionId}
onRetry={handleRetry}
historicalDurations={historicalDurations}
/>
<motion.div
initial={{ opacity: 0 }}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import {
Conversation,
ConversationContent,
Expand All @@ -13,6 +13,7 @@ import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner
import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { TOOL_PART_PREFIX } from "../JobStatsBar/constants";
import { TurnStatsBar } from "../JobStatsBar/TurnStatsBar";
import { useElapsedTimer } from "../JobStatsBar/useElapsedTimer";
import { CopilotPendingReviews } from "../CopilotPendingReviews/CopilotPendingReviews";
import {
buildRenderSegments,
Expand All @@ -37,6 +38,7 @@ interface Props {
isLoading: boolean;
sessionID?: string | null;
onRetry?: () => void;
historicalDurations?: Map<string, number>;
}

function renderSegments(
Expand Down Expand Up @@ -111,6 +113,7 @@ export function ChatMessagesContainer({
isLoading,
sessionID,
onRetry,
historicalDurations,
}: Props) {
const lastMessage = messages[messages.length - 1];
const graphExecId = useMemo(() => extractGraphExecId(messages), [messages]);
Expand Down Expand Up @@ -139,6 +142,25 @@ export function ChatMessagesContainer({
const showThinking =
status === "submitted" || (status === "streaming" && !hasInflight);

const isActivelyStreaming = status === "streaming" || status === "submitted";
const { elapsedSeconds } = useElapsedTimer(isActivelyStreaming);

// Freeze elapsed time when streaming ends so TurnStatsBar shows the final value.
// Reset when a new streaming turn begins.
const frozenElapsedRef = useRef(0);
const wasStreamingRef = useRef(false);
useEffect(() => {
if (isActivelyStreaming) {
if (!wasStreamingRef.current) {
frozenElapsedRef.current = 0;
}
if (elapsedSeconds > 0) {
frozenElapsedRef.current = elapsedSeconds;
}
}
wasStreamingRef.current = isActivelyStreaming;
});

return (
<Conversation className="min-h-0 flex-1">
<ConversationContent className="flex flex-1 flex-col gap-6 px-3 py-6">
Expand Down Expand Up @@ -239,10 +261,19 @@ export function ChatMessagesContainer({
{isLastInTurn && !isCurrentlyStreaming && (
<TurnStatsBar
turnMessages={getTurnMessages(messages, messageIndex)}
elapsedSeconds={
messageIndex === messages.length - 1
? frozenElapsedRef.current
: undefined
}
durationMs={historicalDurations?.get(message.id)}
/>
)}
{isLastAssistant && showThinking && (
<ThinkingIndicator active={showThinking} />
<ThinkingIndicator
active={showThinking}
elapsedSeconds={elapsedSeconds}
/>
)}
</MessageContent>
{message.role === "user" && textParts.length > 0 && (
Expand All @@ -268,7 +299,10 @@ export function ChatMessagesContainer({
{showThinking && lastMessage?.role !== "assistant" && (
<Message from="assistant">
<MessageContent className="text-[1rem] leading-relaxed">
<ThinkingIndicator active={showThinking} />
<ThinkingIndicator
active={showThinking}
elapsedSeconds={elapsedSeconds}
/>
</MessageContent>
</Message>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,93 +1,23 @@
import { useEffect, useRef, useState } from "react";
import { formatElapsed } from "../../JobStatsBar/formatElapsed";
import { ScaleLoader } from "../../ScaleLoader/ScaleLoader";

const THINKING_PHRASES = [
"Thinking...",
"Considering this...",
"Working through this...",
"Analyzing your request...",
"Reasoning...",
"Looking into it...",
"Processing your request...",
"Mulling this over...",
"Piecing it together...",
"On it...",
"Connecting the dots...",
"Exploring possibilities...",
"Weighing options...",
"Diving deeper...",
"Gathering thoughts...",
"Almost there...",
"Figuring this out...",
"Putting it together...",
"Running through ideas...",
"Wrapping my head around this...",
];

const PHRASE_CYCLE_MS = 6_000;
const FADE_DURATION_MS = 300;

/**
* Cycles through thinking phrases sequentially with a fade-out/in transition.
* Returns the current phrase and whether it's visible (for opacity).
*/
function useCyclingPhrase(active: boolean) {
const indexRef = useRef(0);
const [phrase, setPhrase] = useState(THINKING_PHRASES[0]);
const [visible, setVisible] = useState(true);
const fadeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Reset to the first phrase when thinking restarts
const prevActive = useRef(active);
useEffect(() => {
if (active && !prevActive.current) {
indexRef.current = 0;
setPhrase(THINKING_PHRASES[0]);
setVisible(true);
}
prevActive.current = active;
}, [active]);

useEffect(() => {
if (!active) return;
const id = setInterval(() => {
setVisible(false);
fadeTimeoutRef.current = setTimeout(() => {
indexRef.current = (indexRef.current + 1) % THINKING_PHRASES.length;
setPhrase(THINKING_PHRASES[indexRef.current]);
setVisible(true);
}, FADE_DURATION_MS);
}, PHRASE_CYCLE_MS);
return () => {
clearInterval(id);
if (fadeTimeoutRef.current) {
clearTimeout(fadeTimeoutRef.current);
fadeTimeoutRef.current = null;
}
};
}, [active]);

return { phrase, visible };
}
/** Only show elapsed time after this many seconds. */
const SHOW_AFTER_SECONDS = 20;

interface Props {
active: boolean;
elapsedSeconds: number;
}

export function ThinkingIndicator({ active }: Props) {
const { phrase, visible } = useCyclingPhrase(active);
export function ThinkingIndicator({ active, elapsedSeconds }: Props) {
const showTime = active && elapsedSeconds >= SHOW_AFTER_SECONDS;

return (
<span className="inline-flex items-center gap-1.5 text-neutral-500">
<span className="inline-flex items-center gap-1.5 font-mono text-sm text-neutral-500">
<ScaleLoader size={16} />
<span
className="transition-opacity duration-300"
style={{ opacity: visible ? 1 : 0 }}
>
<span className="animate-pulse [animation-duration:1.5s]">
{phrase}
</span>
</span>
{showTime && (
<span className="tabular-nums">{formatElapsed(elapsedSeconds)}</span>
)}
</span>
);
}
Loading
Loading