mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-01 13:45:44 +00:00
Compare commits
8 Commits
experiment
...
agent-mess
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9abf96f487 | ||
|
|
67a6266c97 | ||
|
|
1a076f557d | ||
|
|
087f6d8f6a | ||
|
|
040f779b20 | ||
|
|
107809543b | ||
|
|
95fd5f81a4 | ||
|
|
94ef6974d6 |
@@ -0,0 +1,27 @@
|
||||
"""add processing_duration_seconds to chat_message
|
||||
|
||||
Revision ID: 9d1543a37106
|
||||
Revises: 72aa7de2e5cf
|
||||
Create Date: 2026-01-21 11:42:18.546188
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "9d1543a37106"
|
||||
down_revision = "72aa7de2e5cf"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"chat_message",
|
||||
sa.Column("processing_duration_seconds", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("chat_message", "processing_duration_seconds")
|
||||
@@ -45,6 +45,8 @@ class ChatStateContainer:
|
||||
self.citation_to_doc: CitationMapping = {}
|
||||
# True if this turn is a clarification question (deep research flow)
|
||||
self.is_clarification: bool = False
|
||||
# Tool processing duration (time before answer starts) in seconds
|
||||
self.tool_processing_duration: float | None = None
|
||||
# Note: LLM cost tracking is now handled in multi_llm.py
|
||||
# Search doc collection - maps dedup key to SearchDoc for all docs from tool calls
|
||||
self._all_search_docs: dict[SearchDocKey, SearchDoc] = {}
|
||||
@@ -101,6 +103,16 @@ class ChatStateContainer:
|
||||
with self._lock:
|
||||
return self.is_clarification
|
||||
|
||||
def set_tool_processing_duration(self, duration: float | None) -> None:
|
||||
"""Set the tool processing duration (time before answer starts)."""
|
||||
with self._lock:
|
||||
self.tool_processing_duration = duration
|
||||
|
||||
def get_tool_processing_duration(self) -> float | None:
|
||||
"""Thread-safe getter for tool_processing_duration."""
|
||||
with self._lock:
|
||||
return self.tool_processing_duration
|
||||
|
||||
@staticmethod
|
||||
def create_search_doc_key(
|
||||
search_doc: SearchDoc, use_simple_key: bool = True
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -390,6 +391,9 @@ def run_llm_loop(
|
||||
|
||||
initialize_litellm()
|
||||
|
||||
# Track processing start time for tool duration calculation
|
||||
processing_start_time = time.monotonic()
|
||||
|
||||
# Initialize citation processor for handling citations dynamically
|
||||
# When include_citations is True, use HYPERLINK mode to format citations as [[1]](url)
|
||||
# When include_citations is False, use REMOVE mode to strip citations from output
|
||||
@@ -551,6 +555,11 @@ def run_llm_loop(
|
||||
# This calls the LLM, yields packets (reasoning, answers, etc.) and returns the result
|
||||
# It also pre-processes the tool calls in preparation for running them
|
||||
tool_defs = [tool.tool_definition() for tool in final_tools]
|
||||
|
||||
# Calculate tool processing duration at this point
|
||||
# This captures the time spent on tool calls before the answer starts streaming
|
||||
tool_processing_duration = time.monotonic() - processing_start_time
|
||||
|
||||
llm_step_result, has_reasoned = run_llm_step(
|
||||
emitter=emitter,
|
||||
history=truncated_message_history,
|
||||
@@ -565,6 +574,7 @@ def run_llm_loop(
|
||||
# final set of documents immediately if desired.
|
||||
final_documents=gathered_documents,
|
||||
user_identity=user_identity,
|
||||
tool_processing_duration=tool_processing_duration,
|
||||
)
|
||||
if has_reasoned:
|
||||
reasoning_cycles += 1
|
||||
|
||||
@@ -622,6 +622,7 @@ def run_llm_step_pkt_generator(
|
||||
# TODO: Temporary handling of nested tool calls with agents, figure out a better way to handle this
|
||||
use_existing_tab_index: bool = False,
|
||||
is_deep_research: bool = False,
|
||||
tool_processing_duration: float | None = None,
|
||||
) -> Generator[Packet, None, tuple[LlmStepResult, bool]]:
|
||||
"""Run an LLM step and stream the response as packets.
|
||||
NOTE: DO NOT TOUCH THIS FUNCTION BEFORE ASKING YUHONG, this is very finicky and
|
||||
@@ -822,6 +823,12 @@ def run_llm_step_pkt_generator(
|
||||
reasoning_start = False
|
||||
|
||||
if not answer_start:
|
||||
# Store tool processing duration in state container for save_chat
|
||||
if state_container and tool_processing_duration is not None:
|
||||
state_container.set_tool_processing_duration(
|
||||
tool_processing_duration
|
||||
)
|
||||
|
||||
yield Packet(
|
||||
placement=Placement(
|
||||
turn_index=turn_index,
|
||||
@@ -830,6 +837,7 @@ def run_llm_step_pkt_generator(
|
||||
),
|
||||
obj=AgentResponseStart(
|
||||
final_documents=final_documents,
|
||||
tool_processing_duration_seconds=tool_processing_duration,
|
||||
),
|
||||
)
|
||||
answer_start = True
|
||||
@@ -1038,6 +1046,7 @@ def run_llm_step(
|
||||
max_tokens: int | None = None,
|
||||
use_existing_tab_index: bool = False,
|
||||
is_deep_research: bool = False,
|
||||
tool_processing_duration: float | None = None,
|
||||
) -> tuple[LlmStepResult, bool]:
|
||||
"""Wrapper around run_llm_step_pkt_generator that consumes packets and emits them.
|
||||
|
||||
@@ -1059,6 +1068,7 @@ def run_llm_step(
|
||||
max_tokens=max_tokens,
|
||||
use_existing_tab_index=use_existing_tab_index,
|
||||
is_deep_research=is_deep_research,
|
||||
tool_processing_duration=tool_processing_duration,
|
||||
)
|
||||
|
||||
while True:
|
||||
|
||||
@@ -4,6 +4,7 @@ An overview can be found in the README.md file in this directory.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from uuid import UUID
|
||||
@@ -312,6 +313,7 @@ def handle_stream_message_objects(
|
||||
external_state_container: ChatStateContainer | None = None,
|
||||
) -> AnswerStream:
|
||||
tenant_id = get_current_tenant_id()
|
||||
processing_start_time = time.monotonic()
|
||||
|
||||
llm: LLM | None = None
|
||||
chat_session: ChatSession | None = None
|
||||
@@ -602,6 +604,7 @@ def handle_stream_message_objects(
|
||||
chat_session_id=str(chat_session.id),
|
||||
is_connected=check_is_connected,
|
||||
assistant_message=assistant_response,
|
||||
processing_start_time=processing_start_time,
|
||||
)
|
||||
|
||||
# Run the LLM loop with explicit wrapper for stop signal handling
|
||||
@@ -722,6 +725,7 @@ def llm_loop_completion_handle(
|
||||
db_session: Session,
|
||||
chat_session_id: str,
|
||||
assistant_message: ChatMessage,
|
||||
processing_start_time: float | None = None,
|
||||
) -> None:
|
||||
# Determine if stopped by user
|
||||
completed_normally = is_connected()
|
||||
@@ -753,6 +757,7 @@ def llm_loop_completion_handle(
|
||||
assistant_message=assistant_message,
|
||||
is_clarification=state_container.is_clarification,
|
||||
emitted_citations=state_container.get_emitted_citations(),
|
||||
tool_processing_duration=state_container.get_tool_processing_duration(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ def save_chat_turn(
|
||||
assistant_message: ChatMessage,
|
||||
is_clarification: bool = False,
|
||||
emitted_citations: set[int] | None = None,
|
||||
tool_processing_duration: float | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Save a chat turn by populating the assistant_message and creating related entities.
|
||||
@@ -169,12 +170,17 @@ def save_chat_turn(
|
||||
is_clarification: Whether this assistant message is a clarification question (deep research flow)
|
||||
emitted_citations: Set of citation numbers that were actually emitted during streaming.
|
||||
If provided, only citations in this set will be saved; others are filtered out.
|
||||
tool_processing_duration: Duration of tool processing before answer starts (in seconds)
|
||||
"""
|
||||
# 1. Update ChatMessage with message content, reasoning tokens, and token count
|
||||
assistant_message.message = message_text
|
||||
assistant_message.reasoning_tokens = reasoning_tokens
|
||||
assistant_message.is_clarification = is_clarification
|
||||
|
||||
# Use tool processing duration (captured when MESSAGE_START was emitted)
|
||||
if tool_processing_duration is not None:
|
||||
assistant_message.processing_duration_seconds = tool_processing_duration
|
||||
|
||||
# Calculate token count using default tokenizer, when storing, this should not use the LLM
|
||||
# specific one so we use a system default tokenizer here.
|
||||
default_tokenizer = get_tokenizer(None, None)
|
||||
|
||||
@@ -855,6 +855,7 @@ def translate_db_message_to_chat_message_detail(
|
||||
files=chat_message.files or [],
|
||||
error=chat_message.error,
|
||||
current_feedback=current_feedback,
|
||||
processing_duration_seconds=chat_message.processing_duration_seconds,
|
||||
)
|
||||
|
||||
return chat_msg_detail
|
||||
|
||||
@@ -2173,6 +2173,10 @@ class ChatMessage(Base):
|
||||
)
|
||||
# True if this assistant message is a clarification question (deep research flow)
|
||||
is_clarification: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# Duration in seconds for processing this message (assistant messages only)
|
||||
processing_duration_seconds: Mapped[float | None] = mapped_column(
|
||||
Float, nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
chat_session: Mapped[ChatSession] = relationship("ChatSession")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# 2. Use user provided custom prompts
|
||||
# 3. Save the plan for replay
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
|
||||
@@ -97,6 +98,7 @@ def generate_final_report(
|
||||
citation_mapping: CitationMapping,
|
||||
user_identity: LLMUserIdentity | None,
|
||||
saved_reasoning: str | None = None,
|
||||
tool_processing_duration: float | None = None,
|
||||
) -> bool:
|
||||
"""Generate the final research report.
|
||||
|
||||
@@ -147,6 +149,7 @@ def generate_final_report(
|
||||
user_identity=user_identity,
|
||||
max_tokens=MAX_FINAL_REPORT_TOKENS,
|
||||
is_deep_research=True,
|
||||
tool_processing_duration=tool_processing_duration,
|
||||
)
|
||||
|
||||
# Save citation mapping to state_container so citations are persisted
|
||||
@@ -200,6 +203,9 @@ def run_deep_research_llm_loop(
|
||||
|
||||
initialize_litellm()
|
||||
|
||||
# Track processing start time for tool duration calculation
|
||||
processing_start_time = time.monotonic()
|
||||
|
||||
available_tokens = llm.config.max_input_tokens
|
||||
|
||||
llm_step_result: LlmStepResult | None = None
|
||||
@@ -240,6 +246,9 @@ def run_deep_research_llm_loop(
|
||||
last_n_user_messages=MAX_USER_MESSAGES_FOR_CONTEXT,
|
||||
)
|
||||
|
||||
# Calculate tool processing duration for clarification step
|
||||
# (used if the LLM emits a clarification question instead of calling tools)
|
||||
clarification_tool_duration = time.monotonic() - processing_start_time
|
||||
llm_step_result, _ = run_llm_step(
|
||||
emitter=emitter,
|
||||
history=truncated_message_history,
|
||||
@@ -254,6 +263,7 @@ def run_deep_research_llm_loop(
|
||||
final_documents=None,
|
||||
user_identity=user_identity,
|
||||
is_deep_research=True,
|
||||
tool_processing_duration=clarification_tool_duration,
|
||||
)
|
||||
|
||||
if not llm_step_result.tool_calls:
|
||||
@@ -406,6 +416,8 @@ def run_deep_research_llm_loop(
|
||||
turn_index=report_turn_index,
|
||||
citation_mapping=citation_mapping,
|
||||
user_identity=user_identity,
|
||||
tool_processing_duration=time.monotonic()
|
||||
- processing_start_time,
|
||||
)
|
||||
# Update final_turn_index: base + 1 for the report itself + 1 if reasoning occurred
|
||||
final_turn_index = report_turn_index + (1 if report_reasoned else 0)
|
||||
@@ -493,6 +505,8 @@ def run_deep_research_llm_loop(
|
||||
turn_index=report_turn_index,
|
||||
citation_mapping=citation_mapping,
|
||||
user_identity=user_identity,
|
||||
tool_processing_duration=time.monotonic()
|
||||
- processing_start_time,
|
||||
)
|
||||
final_turn_index = report_turn_index + (1 if report_reasoned else 0)
|
||||
break
|
||||
@@ -513,6 +527,8 @@ def run_deep_research_llm_loop(
|
||||
citation_mapping=citation_mapping,
|
||||
user_identity=user_identity,
|
||||
saved_reasoning=most_recent_reasoning,
|
||||
tool_processing_duration=time.monotonic()
|
||||
- processing_start_time,
|
||||
)
|
||||
final_turn_index = report_turn_index + (1 if report_reasoned else 0)
|
||||
break
|
||||
@@ -574,6 +590,8 @@ def run_deep_research_llm_loop(
|
||||
turn_index=report_turn_index,
|
||||
citation_mapping=citation_mapping,
|
||||
user_identity=user_identity,
|
||||
tool_processing_duration=time.monotonic()
|
||||
- processing_start_time,
|
||||
)
|
||||
final_turn_index = report_turn_index + (
|
||||
1 if report_reasoned else 0
|
||||
|
||||
@@ -313,6 +313,7 @@ class ChatMessageDetail(BaseModel):
|
||||
files: list[FileDescriptor]
|
||||
error: str | None = None
|
||||
current_feedback: str | None = None # "like" | "dislike" | null
|
||||
processing_duration_seconds: float | None = None
|
||||
|
||||
def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore
|
||||
initial_dict = super().model_dump(mode="json", *args, **kwargs) # type: ignore
|
||||
|
||||
@@ -105,6 +105,7 @@ class AgentResponseStart(BaseObj):
|
||||
type: Literal["message_start"] = StreamingType.MESSAGE_START.value
|
||||
|
||||
final_documents: list[SearchDoc] | None = None
|
||||
tool_processing_duration_seconds: float | None = None
|
||||
|
||||
|
||||
# The stream of tokens for the final response
|
||||
|
||||
@@ -10,7 +10,7 @@ const SvgCircle = ({ size, ...props }: IconProps) => (
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="8" cy="8" r="6" strokeWidth={1.5} />
|
||||
<circle cx="8" cy="8" r="4" strokeWidth={1.5} />
|
||||
</svg>
|
||||
);
|
||||
export default SvgCircle;
|
||||
|
||||
45
web/package-lock.json
generated
45
web/package-lock.json
generated
@@ -71,6 +71,7 @@
|
||||
"react-loader-spinner": "^8.0.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-select": "^5.8.0",
|
||||
"react-truncate-markup": "^5.1.2",
|
||||
"recharts": "^2.13.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
@@ -8441,6 +8442,11 @@
|
||||
"version": "1.0.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/computed-style": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/computed-style/-/computed-style-0.1.4.tgz",
|
||||
"integrity": "sha512-WpAmaKbMNmS3OProfHIdJiNleNJdgUrJfbKArXua28QF7+0CoZjlLn0lp6vlc+dl5r2/X9GQiQRQQU4BzSa69w=="
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"dev": true,
|
||||
@@ -12706,6 +12712,18 @@
|
||||
"url": "https://github.com/sponsors/antonk52"
|
||||
}
|
||||
},
|
||||
"node_modules/line-height": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/line-height/-/line-height-0.3.1.tgz",
|
||||
"integrity": "sha512-YExecgqPwnp5gplD2+Y8e8A5+jKpr25+DzMbFdI1/1UAr0FJrTFv4VkHLf8/6B590i1wUPJWMKKldkd/bdQ//w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"computed-style": "~0.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"license": "MIT"
|
||||
@@ -15725,6 +15743,27 @@
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-truncate-markup": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/react-truncate-markup/-/react-truncate-markup-5.1.2.tgz",
|
||||
"integrity": "sha512-eEq6T8Rs+wz98cRYzQECGFNBfXwRYraLg/kz52f6DRBKmzxqB+GYLeDkVe/zrC+2vh5AEwM6nSYFvDWEBljd0w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"line-height": "0.3.1",
|
||||
"memoize-one": "^5.1.1",
|
||||
"prop-types": "^15.6.0",
|
||||
"resize-observer-polyfill": "1.5.x"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-truncate-markup/node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
@@ -16048,6 +16087,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resize-observer-polyfill": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.8",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"react-loader-spinner": "^8.0.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-select": "^5.8.0",
|
||||
"react-truncate-markup": "^5.1.2",
|
||||
"recharts": "^2.13.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
|
||||
@@ -18,7 +18,7 @@ import CardSection from "@/components/admin/CardSection";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
|
||||
import { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
|
||||
import { SEARCH_TOOL_ID } from "@/app/chat/components/tools/constants";
|
||||
import { SEARCH_TOOL_ID } from "@/app/app/components/tools/constants";
|
||||
import { SlackChannelConfigFormFields } from "./SlackChannelConfigFormFields";
|
||||
|
||||
export const SlackChannelConfigCreationForm = ({
|
||||
|
||||
@@ -4,10 +4,10 @@ import { cn, noProp } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import ShareChatSessionModal from "@/app/chat/components/modal/ShareChatSessionModal";
|
||||
import ShareChatSessionModal from "@/app/app/components/modal/ShareChatSessionModal";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import { useProjectsContext } from "@/app/app/projects/ProjectsContext";
|
||||
import useChatSessions from "@/hooks/useChatSessions";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import {
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
showErrorNotification,
|
||||
} from "@/sections/sidebar/sidebarUtils";
|
||||
import { LOCAL_STORAGE_KEYS } from "@/sections/sidebar/constants";
|
||||
import { deleteChatSession } from "@/app/chat/services/lib";
|
||||
import { deleteChatSession } from "@/app/app/services/lib";
|
||||
import { useRouter } from "next/navigation";
|
||||
import MoveCustomAgentChatModal from "@/components/modals/MoveCustomAgentChatModal";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
@@ -5,10 +5,10 @@ import { HealthCheckBanner } from "@/components/health/healthcheck";
|
||||
import {
|
||||
personaIncludesRetrieval,
|
||||
getAvailableContextTokens,
|
||||
} from "@/app/chat/services/lib";
|
||||
} from "@/app/app/services/lib";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
|
||||
import { useFederatedConnectors, useFilters, useLlmManager } from "@/lib/hooks";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
|
||||
@@ -17,15 +17,15 @@ import { useSettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import Dropzone from "react-dropzone";
|
||||
import ChatInputBar, {
|
||||
ChatInputBarHandle,
|
||||
} from "@/app/chat/components/input/ChatInputBar";
|
||||
} from "@/app/app/components/input/ChatInputBar";
|
||||
import useChatSessions from "@/hooks/useChatSessions";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { useTags } from "@/lib/hooks/useTags";
|
||||
import { useDocumentSets } from "@/lib/hooks/useDocumentSets";
|
||||
import { useAgents } from "@/hooks/useAgents";
|
||||
import { ChatPopup } from "@/app/chat/components/ChatPopup";
|
||||
import { ChatPopup } from "@/app/app/components/ChatPopup";
|
||||
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
|
||||
import { SEARCH_TOOL_ID } from "@/app/chat/components/tools/constants";
|
||||
import { SEARCH_TOOL_ID } from "@/app/app/components/tools/constants";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import NoAssistantModal from "@/components/modals/NoAssistantModal";
|
||||
import TextView from "@/components/chat/TextView";
|
||||
@@ -36,33 +36,33 @@ import { getSourceMetadata } from "@/lib/sources";
|
||||
import { SourceMetadata } from "@/lib/search/interfaces";
|
||||
import { FederatedConnectorDetail, UserRole, ValidSources } from "@/lib/types";
|
||||
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
|
||||
import { useChatController } from "@/app/chat/hooks/useChatController";
|
||||
import { useAssistantController } from "@/app/chat/hooks/useAssistantController";
|
||||
import { useChatSessionController } from "@/app/chat/hooks/useChatSessionController";
|
||||
import { useDeepResearchToggle } from "@/app/chat/hooks/useDeepResearchToggle";
|
||||
import { useIsDefaultAgent } from "@/app/chat/hooks/useIsDefaultAgent";
|
||||
import { useChatController } from "@/app/app/hooks/useChatController";
|
||||
import { useAssistantController } from "@/app/app/hooks/useAssistantController";
|
||||
import { useChatSessionController } from "@/app/app/hooks/useChatSessionController";
|
||||
import { useDeepResearchToggle } from "@/app/app/hooks/useDeepResearchToggle";
|
||||
import { useIsDefaultAgent } from "@/app/app/hooks/useIsDefaultAgent";
|
||||
import {
|
||||
useChatSessionStore,
|
||||
useCurrentMessageHistory,
|
||||
} from "@/app/chat/stores/useChatSessionStore";
|
||||
} from "@/app/app/stores/useChatSessionStore";
|
||||
import {
|
||||
useCurrentChatState,
|
||||
useIsReady,
|
||||
useDocumentSidebarVisible,
|
||||
} from "@/app/chat/stores/useChatSessionStore";
|
||||
} from "@/app/app/stores/useChatSessionStore";
|
||||
import FederatedOAuthModal from "@/components/chat/FederatedOAuthModal";
|
||||
import ChatScrollContainer, {
|
||||
ChatScrollContainerHandle,
|
||||
} from "@/components/chat/ChatScrollContainer";
|
||||
import MessageList from "@/components/chat/MessageList";
|
||||
import WelcomeMessage from "@/app/chat/components/WelcomeMessage";
|
||||
import ProjectContextPanel from "@/app/chat/components/projects/ProjectContextPanel";
|
||||
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
|
||||
import ProjectContextPanel from "@/app/app/components/projects/ProjectContextPanel";
|
||||
import { useProjectsContext } from "@/app/app/projects/ProjectsContext";
|
||||
import {
|
||||
getProjectTokenCount,
|
||||
getMaxSelectedDocumentTokens,
|
||||
} from "@/app/chat/projects/projectsService";
|
||||
import ProjectChatSessionList from "@/app/chat/components/projects/ProjectChatSessionList";
|
||||
} from "@/app/app/projects/projectsService";
|
||||
import ProjectChatSessionList from "@/app/app/components/projects/ProjectChatSessionList";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Suggestions from "@/sections/Suggestions";
|
||||
import OnboardingFlow from "@/refresh-components/onboarding/OnboardingFlow";
|
||||
@@ -70,7 +70,7 @@ import { OnboardingStep } from "@/refresh-components/onboarding/types";
|
||||
import { useShowOnboarding } from "@/hooks/useShowOnboarding";
|
||||
import * as AppLayouts from "@/layouts/app-layouts";
|
||||
import { SvgChevronDown, SvgFileText } from "@opal/icons";
|
||||
import ChatHeader from "@/app/chat/components/ChatHeader";
|
||||
import ChatHeader from "@/app/app/components/ChatHeader";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { FileDescriptor } from "@/app/chat/interfaces";
|
||||
import { FileDescriptor } from "@/app/app/interfaces";
|
||||
import { FiLoader, FiFileText } from "react-icons/fi";
|
||||
import { InputBarPreviewImage } from "./images/InputBarPreviewImage";
|
||||
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { buildImgUrl } from "@/app/chat/components/files/images/utils";
|
||||
import { buildImgUrl } from "@/app/app/components/files/images/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { FiDownload } from "react-icons/fi";
|
||||
import { ImageShape } from "@/app/chat/services/streamingModels";
|
||||
import { FullImageModal } from "@/app/chat/components/files/images/FullImageModal";
|
||||
import { buildImgUrl } from "@/app/chat/components/files/images/utils";
|
||||
import { ImageShape } from "@/app/app/services/streamingModels";
|
||||
import { FullImageModal } from "@/app/app/components/files/images/FullImageModal";
|
||||
import { buildImgUrl } from "@/app/app/components/files/images/utils";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, ReactNode, forwardRef } from "react";
|
||||
import { Folder } from "./interfaces";
|
||||
import { ChatSession } from "@/app/chat/interfaces";
|
||||
import { ChatSession } from "@/app/app/interfaces";
|
||||
import { Caret } from "@/components/icons/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChatSession } from "@/app/chat/interfaces";
|
||||
import { ChatSession } from "@/app/app/interfaces";
|
||||
|
||||
export interface Folder {
|
||||
folder_id?: number;
|
||||
@@ -11,24 +11,24 @@ import React, {
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
|
||||
import LLMPopover from "@/refresh-components/popovers/LLMPopover";
|
||||
import { InputPrompt } from "@/app/chat/interfaces";
|
||||
import { InputPrompt } from "@/app/app/interfaces";
|
||||
import { FilterManager, LlmManager, useFederatedConnectors } from "@/lib/hooks";
|
||||
import usePromptShortcuts from "@/hooks/usePromptShortcuts";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { ChatState } from "@/app/chat/interfaces";
|
||||
import { ChatState } from "@/app/app/interfaces";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import { getFormattedDateRangeString } from "@/lib/dateUtils";
|
||||
import { truncateString, cn } from "@/lib/utils";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import { FileCard } from "@/app/chat/components/input/FileCard";
|
||||
import { useProjectsContext } from "@/app/app/projects/ProjectsContext";
|
||||
import { FileCard } from "@/app/app/components/input/FileCard";
|
||||
import {
|
||||
ProjectFile,
|
||||
UserFileStatus,
|
||||
} from "@/app/chat/projects/projectsService";
|
||||
} from "@/app/app/projects/projectsService";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import FilePickerPopover from "@/refresh-components/popovers/FilePickerPopover";
|
||||
import ActionsPopover from "@/refresh-components/popovers/ActionsPopover";
|
||||
@@ -36,7 +36,7 @@ import SelectButton from "@/refresh-components/buttons/SelectButton";
|
||||
import {
|
||||
getIconForAction,
|
||||
hasSearchToolsAvailable,
|
||||
} from "@/app/chat/services/actionUtils";
|
||||
} from "@/app/app/services/actionUtils";
|
||||
import {
|
||||
SvgArrowUp,
|
||||
SvgCalendar,
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import type { ProjectFile } from "@/app/chat/projects/projectsService";
|
||||
import { UserFileStatus } from "@/app/chat/projects/projectsService";
|
||||
import type { ProjectFile } from "@/app/app/projects/projectsService";
|
||||
import { UserFileStatus } from "@/app/app/projects/projectsService";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Truncated from "@/refresh-components/texts/Truncated";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
@@ -4,8 +4,8 @@ import { useState } from "react";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { Callout } from "@/components/ui/callout";
|
||||
import Text from "@/components/ui/text";
|
||||
import { ChatSession, ChatSessionSharedStatus } from "@/app/chat/interfaces";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
|
||||
import { ChatSession, ChatSessionSharedStatus } from "@/app/app/interfaces";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { structureValue } from "@/lib/llm/utils";
|
||||
import { LlmDescriptor, useLlmManager } from "@/lib/hooks";
|
||||
@@ -14,10 +14,10 @@ import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCurrentAgent } from "@/hooks/useAgents";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useChatSessionStore } from "@/app/chat/stores/useChatSessionStore";
|
||||
import { useChatSessionStore } from "@/app/app/stores/useChatSessionStore";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import { copyAll } from "@/app/chat/message/copyingUtils";
|
||||
import { copyAll } from "@/app/app/message/copyingUtils";
|
||||
import { SvgCopy, SvgShare } from "@opal/icons";
|
||||
|
||||
function buildShareLink(chatSessionId: string) {
|
||||
@@ -4,7 +4,7 @@ import React, { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChatSessionMorePopup } from "@/components/sidebar/ChatSessionMorePopup";
|
||||
import { useProjectsContext } from "../../projects/ProjectsContext";
|
||||
import { ChatSession } from "@/app/chat/interfaces";
|
||||
import { ChatSession } from "@/app/app/interfaces";
|
||||
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
|
||||
import { useAgents } from "@/hooks/useAgents";
|
||||
import { formatRelativeTime } from "./project_utils";
|
||||
@@ -18,7 +18,7 @@ import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import { FileCard } from "../input/FileCard";
|
||||
import { hasNonImageFiles } from "@/lib/utils";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import { FileCardSkeleton } from "@/app/chat/components/input/FileCard";
|
||||
import { FileCardSkeleton } from "@/app/app/components/input/FileCard";
|
||||
import ButtonRenaming from "@/refresh-components/buttons/ButtonRenaming";
|
||||
import { UserFileStatus } from "../../projects/projectsService";
|
||||
import { SvgAddLines, SvgEdit, SvgFiles, SvgFolderOpen } from "@opal/icons";
|
||||
@@ -69,7 +69,7 @@ import {
|
||||
useCurrentMessageHistory,
|
||||
} from "../stores/useChatSessionStore";
|
||||
import { Packet, MessageStart, PacketType } from "../services/streamingModels";
|
||||
import { useAssistantPreferences } from "@/app/chat/hooks/useAssistantPreferences";
|
||||
import { useAssistantPreferences } from "@/app/app/hooks/useAssistantPreferences";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import { ProjectFile, useProjectsContext } from "../projects/ProjectsContext";
|
||||
import { useAppParams } from "@/hooks/appNavigation";
|
||||
@@ -173,6 +173,9 @@ export function useChatController({
|
||||
(state) => state.setAbortController
|
||||
);
|
||||
const setIsReady = useChatSessionStore((state) => state.setIsReady);
|
||||
const setStreamingStartTime = useChatSessionStore(
|
||||
(state) => state.setStreamingStartTime
|
||||
);
|
||||
|
||||
// Use custom hooks for accessing store data
|
||||
const currentMessageTree = useCurrentMessageTree();
|
||||
@@ -342,6 +345,7 @@ export function useChatController({
|
||||
|
||||
// Update chat state to input immediately for good UX
|
||||
// The stream will close naturally when the backend sends the STOP packet
|
||||
setStreamingStartTime(currentSession, null);
|
||||
updateChatStateAction(currentSession, "input");
|
||||
}, [currentMessageHistory, currentMessageTree]);
|
||||
|
||||
@@ -734,6 +738,14 @@ export function useChatController({
|
||||
// We've processed initial packets and are starting to stream content.
|
||||
// Transition from 'loading' to 'streaming'.
|
||||
updateChatStateAction(frozenSessionId, "streaming");
|
||||
// Only set start time once (guard prevents reset on each packet)
|
||||
// Use getState() to avoid stale closure - sessions captured at render time becomes stale in async loop
|
||||
if (
|
||||
!useChatSessionStore.getState().sessions.get(frozenSessionId)
|
||||
?.streamingStartTime
|
||||
) {
|
||||
setStreamingStartTime(frozenSessionId, Date.now());
|
||||
}
|
||||
|
||||
if ((packet as MessageResponseIDInfo).user_message_id) {
|
||||
newUserMessageId = (packet as MessageResponseIDInfo)
|
||||
@@ -859,8 +871,17 @@ export function useChatController({
|
||||
overridden_model: finalMessage?.overridden_model,
|
||||
stopReason: stopReason,
|
||||
packets: packets,
|
||||
packetsVersion: packetsVersion,
|
||||
packetCount: packets.length,
|
||||
processingDurationSeconds:
|
||||
finalMessage?.processing_duration_seconds ??
|
||||
(() => {
|
||||
const startTime = useChatSessionStore
|
||||
.getState()
|
||||
.getStreamingStartTime(frozenSessionId);
|
||||
return startTime
|
||||
? Math.floor((Date.now() - startTime) / 1000)
|
||||
: undefined;
|
||||
})(),
|
||||
},
|
||||
],
|
||||
// Pass the latest map state
|
||||
@@ -910,6 +931,7 @@ export function useChatController({
|
||||
}
|
||||
|
||||
resetRegenerationState(frozenSessionId);
|
||||
setStreamingStartTime(frozenSessionId, null);
|
||||
updateChatStateAction(frozenSessionId, "input");
|
||||
|
||||
// Name the chat now that we have the first AI response (navigation already happened before streaming)
|
||||
@@ -139,8 +139,6 @@ export interface Message {
|
||||
|
||||
// new gen
|
||||
packets: Packet[];
|
||||
// Version counter for efficient memo comparison (increments with each packet)
|
||||
packetsVersion?: number;
|
||||
packetCount?: number; // Tracks packet count for React memo comparison (avoids reading from mutated array)
|
||||
|
||||
// cached values for easy access
|
||||
@@ -149,6 +147,9 @@ export interface Message {
|
||||
|
||||
// feedback state
|
||||
currentFeedback?: FeedbackType | null;
|
||||
|
||||
// Duration in seconds for processing this message (assistant messages only)
|
||||
processingDurationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface BackendChatSession {
|
||||
@@ -198,6 +199,8 @@ export interface BackendMessage {
|
||||
files: FileDescriptor[];
|
||||
tool_call: ToolCallFinalResult | null;
|
||||
current_feedback: string | null;
|
||||
// Duration in seconds for processing this message (assistant messages only)
|
||||
processing_duration_seconds?: number;
|
||||
|
||||
sub_questions: SubQuestionDetail[];
|
||||
// Keeping existing properties
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChatFileType, FileDescriptor } from "@/app/chat/interfaces";
|
||||
import { ChatFileType, FileDescriptor } from "@/app/app/interfaces";
|
||||
import Attachment from "@/refresh-components/Attachment";
|
||||
import { InMessageImage } from "@/app/chat/components/files/images/InMessageImage";
|
||||
import { InMessageImage } from "@/app/app/components/files/images/InMessageImage";
|
||||
import CsvContent from "@/components/tools/CSVContent";
|
||||
import TextView from "@/components/chat/TextView";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { FileDescriptor } from "@/app/chat/interfaces";
|
||||
import { FileDescriptor } from "@/app/app/interfaces";
|
||||
import "katex/dist/katex.min.css";
|
||||
import MessageSwitcher from "@/app/chat/message/MessageSwitcher";
|
||||
import MessageSwitcher from "@/app/app/message/MessageSwitcher";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
@@ -1,10 +1,9 @@
|
||||
import {
|
||||
Citation,
|
||||
QuestionCardProps,
|
||||
DocumentCardProps,
|
||||
} from "@/components/search/results/Citation";
|
||||
import { LoadedOnyxDocument, OnyxDocument } from "@/lib/search/interfaces";
|
||||
import React, { memo, JSX } from "react";
|
||||
import React, { memo, JSX, useMemo, useCallback } from "react";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { WebResultIcon } from "@/components/WebResultIcon";
|
||||
import { SubQuestionDetail, CitationMap } from "../interfaces";
|
||||
@@ -13,6 +12,13 @@ import { ProjectFile } from "../projects/projectsService";
|
||||
import { BlinkingDot } from "./BlinkingDot";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import SourceTag from "@/refresh-components/buttons/source-tag/SourceTag";
|
||||
import {
|
||||
documentToSourceInfo,
|
||||
questionToSourceInfo,
|
||||
getDisplayNameForSource,
|
||||
} from "@/refresh-components/buttons/source-tag/sourceTagUtils";
|
||||
import { openDocument } from "@/lib/search/utils";
|
||||
|
||||
export const MemoizedAnchor = memo(
|
||||
({
|
||||
@@ -124,38 +130,48 @@ export const MemoizedLink = memo(
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
const value = rest.children;
|
||||
const questionCardProps: QuestionCardProps | undefined =
|
||||
question && openQuestion
|
||||
? {
|
||||
question: question,
|
||||
openQuestion: openQuestion,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const documentCardProps: DocumentCardProps | undefined =
|
||||
document && updatePresentingDocument
|
||||
? {
|
||||
url: document.link,
|
||||
document: document as LoadedOnyxDocument,
|
||||
updatePresentingDocument: updatePresentingDocument!,
|
||||
}
|
||||
: undefined;
|
||||
// Convert document to SourceInfo for SourceTag
|
||||
const documentSourceInfo = useMemo(() => {
|
||||
if (!document) return null;
|
||||
return documentToSourceInfo(document as OnyxDocument);
|
||||
}, [document]);
|
||||
|
||||
// Convert question to SourceInfo for SourceTag
|
||||
const questionSourceInfo = useMemo(() => {
|
||||
if (!question) return null;
|
||||
return questionToSourceInfo(question, question.level_question_num);
|
||||
}, [question]);
|
||||
|
||||
// Handle click on SourceTag
|
||||
const handleSourceClick = useCallback(() => {
|
||||
if (document && updatePresentingDocument) {
|
||||
openDocument(document as OnyxDocument, updatePresentingDocument);
|
||||
} else if (question && openQuestion) {
|
||||
openQuestion(question);
|
||||
}
|
||||
}, [document, updatePresentingDocument, question, openQuestion]);
|
||||
|
||||
if (value?.toString().startsWith("*")) {
|
||||
return <BlinkingDot addMargin />;
|
||||
} else if (value?.toString().startsWith("[")) {
|
||||
const sourceInfo = documentSourceInfo || questionSourceInfo;
|
||||
if (!sourceInfo) {
|
||||
return <>{rest.children}</>;
|
||||
}
|
||||
|
||||
const displayName = document
|
||||
? getDisplayNameForSource(document as OnyxDocument)
|
||||
: question?.question || "Question";
|
||||
|
||||
return (
|
||||
<>
|
||||
{documentCardProps ? (
|
||||
<Citation document_info={documentCardProps}>
|
||||
{rest.children}
|
||||
</Citation>
|
||||
) : (
|
||||
<Citation question_info={questionCardProps}>
|
||||
{rest.children}
|
||||
</Citation>
|
||||
)}
|
||||
</>
|
||||
<SourceTag
|
||||
inlineCitation
|
||||
displayName={displayName}
|
||||
sources={[sourceInfo]}
|
||||
onSourceClick={handleSourceClick}
|
||||
showDetailsCard
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
253
web/src/app/app/message/messageComponents/AgentMessage.tsx
Normal file
253
web/src/app/app/message/messageComponents/AgentMessage.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { useRef, RefObject, useMemo } from "react";
|
||||
import { Packet, StopReason } from "@/app/app/services/streamingModels";
|
||||
import { FullChatState } from "@/app/app/message/messageComponents/interfaces";
|
||||
import { FeedbackType } from "@/app/app/interfaces";
|
||||
import { handleCopy } from "@/app/app/message/copyingUtils";
|
||||
import { useMessageSwitching } from "@/app/app/message/messageComponents/hooks/useMessageSwitching";
|
||||
import { RendererComponent } from "@/app/app/message/messageComponents/renderMessageComponent";
|
||||
import { usePacketProcessor } from "@/app/app/message/messageComponents/timeline/hooks/usePacketProcessor";
|
||||
import { usePacedTurnGroups } from "@/app/app/message/messageComponents/timeline/hooks/usePacedTurnGroups";
|
||||
import MessageToolbar from "@/app/app/message/messageComponents/MessageToolbar";
|
||||
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
|
||||
import { Message } from "@/app/app/interfaces";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { AgentTimeline } from "@/app/app/message/messageComponents/timeline/AgentTimeline";
|
||||
|
||||
// Type for the regeneration factory function passed from ChatUI
|
||||
export type RegenerationFactory = (regenerationRequest: {
|
||||
messageId: number;
|
||||
parentMessage: Message;
|
||||
forceSearch?: boolean;
|
||||
}) => (modelOverride: LlmDescriptor) => Promise<void>;
|
||||
|
||||
export interface AgentMessageProps {
|
||||
rawPackets: Packet[];
|
||||
packetCount?: number; // Tracked separately for React memo comparison (avoids reading from mutated array)
|
||||
chatState: FullChatState;
|
||||
nodeId: number;
|
||||
messageId?: number;
|
||||
currentFeedback?: FeedbackType | null;
|
||||
llmManager: LlmManager | null;
|
||||
otherMessagesCanSwitchTo?: number[];
|
||||
onMessageSelection?: (nodeId: number) => void;
|
||||
// Stable regeneration callback - takes (parentMessage) and returns a function that takes (modelOverride)
|
||||
onRegenerate?: RegenerationFactory;
|
||||
// Parent message needed to construct regeneration request
|
||||
parentMessage?: Message | null;
|
||||
// Duration in seconds for processing this message (assistant messages only)
|
||||
processingDurationSeconds?: number;
|
||||
}
|
||||
|
||||
// TODO: Consider more robust comparisons:
|
||||
// - `chatState.docs`, `chatState.citations`, and `otherMessagesCanSwitchTo` use
|
||||
// reference equality. Shallow array/object comparison would be more robust if
|
||||
// these are recreated with the same values.
|
||||
function arePropsEqual(
|
||||
prev: AgentMessageProps,
|
||||
next: AgentMessageProps
|
||||
): boolean {
|
||||
return (
|
||||
prev.nodeId === next.nodeId &&
|
||||
prev.messageId === next.messageId &&
|
||||
prev.currentFeedback === next.currentFeedback &&
|
||||
// Compare packetCount (primitive) instead of rawPackets.length
|
||||
// The array is mutated in place, so reading .length from prev and next would return same value
|
||||
prev.packetCount === next.packetCount &&
|
||||
prev.chatState.assistant?.id === next.chatState.assistant?.id &&
|
||||
prev.chatState.docs === next.chatState.docs &&
|
||||
prev.chatState.citations === next.chatState.citations &&
|
||||
prev.chatState.overriddenModel === next.chatState.overriddenModel &&
|
||||
prev.chatState.researchType === next.chatState.researchType &&
|
||||
prev.otherMessagesCanSwitchTo === next.otherMessagesCanSwitchTo &&
|
||||
prev.onRegenerate === next.onRegenerate &&
|
||||
prev.parentMessage?.messageId === next.parentMessage?.messageId &&
|
||||
prev.llmManager?.isLoadingProviders ===
|
||||
next.llmManager?.isLoadingProviders &&
|
||||
prev.processingDurationSeconds === next.processingDurationSeconds
|
||||
// Skip: chatState.regenerate, chatState.setPresentingDocument,
|
||||
// most of llmManager, onMessageSelection (function/object props)
|
||||
);
|
||||
}
|
||||
|
||||
const AgentMessage = React.memo(function AgentMessage({
|
||||
rawPackets,
|
||||
chatState,
|
||||
nodeId,
|
||||
messageId,
|
||||
currentFeedback,
|
||||
llmManager,
|
||||
otherMessagesCanSwitchTo,
|
||||
onMessageSelection,
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
processingDurationSeconds,
|
||||
}: AgentMessageProps) {
|
||||
const markdownRef = useRef<HTMLDivElement>(null);
|
||||
const finalAnswerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Process streaming packets: returns data and callbacks
|
||||
// Hook handles all state internally, exposes clean API
|
||||
const {
|
||||
citations,
|
||||
citationMap,
|
||||
documentMap,
|
||||
toolGroups,
|
||||
toolTurnGroups,
|
||||
displayGroups,
|
||||
hasSteps,
|
||||
stopPacketSeen,
|
||||
stopReason,
|
||||
isGeneratingImage,
|
||||
generatedImageCount,
|
||||
isComplete,
|
||||
onRenderComplete,
|
||||
finalAnswerComing,
|
||||
toolProcessingDuration,
|
||||
} = usePacketProcessor(rawPackets, nodeId);
|
||||
|
||||
// Apply pacing delays between different tool types for smoother visual transitions
|
||||
const { pacedTurnGroups, pacedDisplayGroups, pacedFinalAnswerComing } =
|
||||
usePacedTurnGroups(
|
||||
toolTurnGroups,
|
||||
displayGroups,
|
||||
stopPacketSeen,
|
||||
nodeId,
|
||||
finalAnswerComing
|
||||
);
|
||||
|
||||
// Memoize merged citations separately to avoid creating new object when neither source changed
|
||||
const mergedCitations = useMemo(
|
||||
() => ({
|
||||
...chatState.citations,
|
||||
...citationMap,
|
||||
}),
|
||||
[chatState.citations, citationMap]
|
||||
);
|
||||
|
||||
// Create a chatState that uses streaming citations for immediate rendering
|
||||
// This merges the prop citations with streaming citations, preferring streaming ones
|
||||
// Memoized with granular dependencies to prevent cascading re-renders
|
||||
// Note: chatState object is recreated upstream on every render, so we depend on
|
||||
// individual fields instead of the whole object for proper memoization
|
||||
const effectiveChatState = useMemo<FullChatState>(
|
||||
() => ({
|
||||
...chatState,
|
||||
citations: mergedCitations,
|
||||
}),
|
||||
[
|
||||
chatState.assistant,
|
||||
chatState.docs,
|
||||
chatState.setPresentingDocument,
|
||||
chatState.overriddenModel,
|
||||
chatState.researchType,
|
||||
mergedCitations,
|
||||
]
|
||||
);
|
||||
|
||||
// Message switching logic
|
||||
const {
|
||||
currentMessageInd,
|
||||
includeMessageSwitcher,
|
||||
getPreviousMessage,
|
||||
getNextMessage,
|
||||
} = useMessageSwitching({
|
||||
nodeId,
|
||||
otherMessagesCanSwitchTo,
|
||||
onMessageSelection,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pb-5 md:pt-5 flex flex-col gap-3"
|
||||
data-testid={isComplete ? "onyx-ai-message" : undefined}
|
||||
>
|
||||
{/* Row 1: Two-column layout for tool steps */}
|
||||
|
||||
<AgentTimeline
|
||||
turnGroups={pacedTurnGroups}
|
||||
chatState={effectiveChatState}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
hasDisplayContent={pacedDisplayGroups.length > 0}
|
||||
processingDurationSeconds={processingDurationSeconds}
|
||||
isGeneratingImage={isGeneratingImage}
|
||||
generatedImageCount={generatedImageCount}
|
||||
finalAnswerComing={pacedFinalAnswerComing}
|
||||
toolProcessingDuration={toolProcessingDuration}
|
||||
/>
|
||||
|
||||
{/* Row 2: Display content + MessageToolbar */}
|
||||
<div
|
||||
ref={markdownRef}
|
||||
className="overflow-x-visible focus:outline-none select-text cursor-text px-3"
|
||||
onCopy={(e) => {
|
||||
if (markdownRef.current) {
|
||||
handleCopy(e, markdownRef as RefObject<HTMLDivElement>);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pacedDisplayGroups.length > 0 && (
|
||||
<div ref={finalAnswerRef}>
|
||||
{pacedDisplayGroups.map((displayGroup, index) => (
|
||||
<RendererComponent
|
||||
key={`${displayGroup.turn_index}-${displayGroup.tab_index}`}
|
||||
packets={displayGroup.packets}
|
||||
chatState={effectiveChatState}
|
||||
onComplete={() => {
|
||||
// Only mark complete on the last display group
|
||||
// Hook handles the finalAnswerComing check internally
|
||||
if (index === pacedDisplayGroups.length - 1) {
|
||||
onRenderComplete();
|
||||
}
|
||||
}}
|
||||
animate={false}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
>
|
||||
{(results) => (
|
||||
<>
|
||||
{results.map((r, i) => (
|
||||
<div key={i}>{r.content}</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</RendererComponent>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Show stopped message when user cancelled and no display content */}
|
||||
{pacedDisplayGroups.length === 0 &&
|
||||
stopReason === StopReason.USER_CANCELLED && (
|
||||
<Text as="p" secondaryBody text04>
|
||||
User has stopped generation
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback buttons - only show when streaming and rendering complete */}
|
||||
{isComplete && (
|
||||
<MessageToolbar
|
||||
nodeId={nodeId}
|
||||
messageId={messageId}
|
||||
includeMessageSwitcher={includeMessageSwitcher}
|
||||
currentMessageInd={currentMessageInd}
|
||||
otherMessagesCanSwitchTo={otherMessagesCanSwitchTo}
|
||||
getPreviousMessage={getPreviousMessage}
|
||||
getNextMessage={getNextMessage}
|
||||
onMessageSelection={onMessageSelection}
|
||||
rawPackets={rawPackets}
|
||||
finalAnswerRef={finalAnswerRef}
|
||||
currentFeedback={currentFeedback}
|
||||
onRegenerate={onRegenerate}
|
||||
parentMessage={parentMessage}
|
||||
llmManager={llmManager}
|
||||
currentModelName={chatState.overriddenModel}
|
||||
citations={citations}
|
||||
documentMap={documentMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, arePropsEqual);
|
||||
|
||||
export default AgentMessage;
|
||||
323
web/src/app/app/message/messageComponents/MessageToolbar.tsx
Normal file
323
web/src/app/app/message/messageComponents/MessageToolbar.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { RefObject, useState, useCallback, useMemo } from "react";
|
||||
import { Packet, StreamingCitation } from "@/app/app/services/streamingModels";
|
||||
import { FeedbackType } from "@/app/app/interfaces";
|
||||
import { OnyxDocument } from "@/lib/search/interfaces";
|
||||
import { TooltipGroup } from "@/components/tooltip/CustomTooltip";
|
||||
import {
|
||||
useChatSessionStore,
|
||||
useDocumentSidebarVisible,
|
||||
useSelectedNodeForDocDisplay,
|
||||
} from "@/app/app/stores/useChatSessionStore";
|
||||
import {
|
||||
handleCopy,
|
||||
convertMarkdownTablesToTsv,
|
||||
} from "@/app/app/message/copyingUtils";
|
||||
import { getTextContent } from "@/app/app/services/packetUtils";
|
||||
import { removeThinkingTokens } from "@/app/app/services/thinkingTokens";
|
||||
import MessageSwitcher from "@/app/app/message/MessageSwitcher";
|
||||
import SourceTag from "@/refresh-components/buttons/source-tag/SourceTag";
|
||||
import { citationsToSourceInfoArray } from "@/refresh-components/buttons/source-tag/sourceTagUtils";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import LLMPopover from "@/refresh-components/popovers/LLMPopover";
|
||||
import { parseLlmDescriptor } from "@/lib/llm/utils";
|
||||
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
|
||||
import { Message } from "@/app/app/interfaces";
|
||||
import { SvgThumbsDown, SvgThumbsUp } from "@opal/icons";
|
||||
import { RegenerationFactory } from "./AgentMessage";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import useFeedbackController from "@/hooks/useFeedbackController";
|
||||
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
|
||||
import FeedbackModal, {
|
||||
FeedbackModalProps,
|
||||
} from "@/sections/modals/FeedbackModal";
|
||||
|
||||
// Wrapper component for SourceTag in toolbar to handle memoization
|
||||
const SourcesTagWrapper = React.memo(function SourcesTagWrapper({
|
||||
citations,
|
||||
documentMap,
|
||||
nodeId,
|
||||
selectedMessageForDocDisplay,
|
||||
documentSidebarVisible,
|
||||
updateCurrentDocumentSidebarVisible,
|
||||
updateCurrentSelectedNodeForDocDisplay,
|
||||
}: {
|
||||
citations: StreamingCitation[];
|
||||
documentMap: Map<string, OnyxDocument>;
|
||||
nodeId: number;
|
||||
selectedMessageForDocDisplay: number | null;
|
||||
documentSidebarVisible: boolean;
|
||||
updateCurrentDocumentSidebarVisible: (visible: boolean) => void;
|
||||
updateCurrentSelectedNodeForDocDisplay: (nodeId: number | null) => void;
|
||||
}) {
|
||||
// Convert citations to SourceInfo array
|
||||
const sources = useMemo(
|
||||
() => citationsToSourceInfoArray(citations, documentMap),
|
||||
[citations, documentMap]
|
||||
);
|
||||
|
||||
// Handle click to toggle sidebar
|
||||
const handleSourceClick = useCallback(() => {
|
||||
if (selectedMessageForDocDisplay === nodeId && documentSidebarVisible) {
|
||||
updateCurrentDocumentSidebarVisible(false);
|
||||
updateCurrentSelectedNodeForDocDisplay(null);
|
||||
} else {
|
||||
updateCurrentSelectedNodeForDocDisplay(nodeId);
|
||||
updateCurrentDocumentSidebarVisible(true);
|
||||
}
|
||||
}, [
|
||||
nodeId,
|
||||
selectedMessageForDocDisplay,
|
||||
documentSidebarVisible,
|
||||
updateCurrentDocumentSidebarVisible,
|
||||
updateCurrentSelectedNodeForDocDisplay,
|
||||
]);
|
||||
|
||||
if (sources.length === 0) return null;
|
||||
|
||||
return (
|
||||
<SourceTag
|
||||
displayName="Sources"
|
||||
sources={sources}
|
||||
onSourceClick={handleSourceClick}
|
||||
toggleSource
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export interface MessageToolbarProps {
|
||||
// Message identification
|
||||
nodeId: number;
|
||||
messageId?: number;
|
||||
|
||||
// Message switching
|
||||
includeMessageSwitcher: boolean;
|
||||
currentMessageInd: number | null | undefined;
|
||||
otherMessagesCanSwitchTo?: number[];
|
||||
getPreviousMessage: () => number | undefined;
|
||||
getNextMessage: () => number | undefined;
|
||||
onMessageSelection?: (nodeId: number) => void;
|
||||
|
||||
// Copy functionality
|
||||
rawPackets: Packet[];
|
||||
finalAnswerRef: RefObject<HTMLDivElement | null>;
|
||||
|
||||
// Feedback
|
||||
currentFeedback?: FeedbackType | null;
|
||||
|
||||
// Regeneration
|
||||
onRegenerate?: RegenerationFactory;
|
||||
parentMessage?: Message | null;
|
||||
llmManager: LlmManager | null;
|
||||
currentModelName?: string;
|
||||
|
||||
// Citations
|
||||
citations: StreamingCitation[];
|
||||
documentMap: Map<string, OnyxDocument>;
|
||||
}
|
||||
|
||||
export default function MessageToolbar({
|
||||
nodeId,
|
||||
messageId,
|
||||
includeMessageSwitcher,
|
||||
currentMessageInd,
|
||||
otherMessagesCanSwitchTo,
|
||||
getPreviousMessage,
|
||||
getNextMessage,
|
||||
onMessageSelection,
|
||||
rawPackets,
|
||||
finalAnswerRef,
|
||||
currentFeedback,
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
llmManager,
|
||||
currentModelName,
|
||||
citations,
|
||||
documentMap,
|
||||
}: MessageToolbarProps) {
|
||||
// Document sidebar state - managed internally to reduce prop drilling
|
||||
const documentSidebarVisible = useDocumentSidebarVisible();
|
||||
const selectedMessageForDocDisplay = useSelectedNodeForDocDisplay();
|
||||
const updateCurrentDocumentSidebarVisible = useChatSessionStore(
|
||||
(state) => state.updateCurrentDocumentSidebarVisible
|
||||
);
|
||||
const updateCurrentSelectedNodeForDocDisplay = useChatSessionStore(
|
||||
(state) => state.updateCurrentSelectedNodeForDocDisplay
|
||||
);
|
||||
|
||||
// Feedback modal state and handlers
|
||||
const { popup, setPopup } = usePopup();
|
||||
const { handleFeedbackChange } = useFeedbackController({ setPopup });
|
||||
const modal = useCreateModal();
|
||||
const [feedbackModalProps, setFeedbackModalProps] =
|
||||
useState<FeedbackModalProps | null>(null);
|
||||
|
||||
// Helper to check if feedback button should be in transient state
|
||||
const isFeedbackTransient = useCallback(
|
||||
(feedbackType: "like" | "dislike") => {
|
||||
const hasCurrentFeedback = currentFeedback === feedbackType;
|
||||
if (!modal.isOpen) return hasCurrentFeedback;
|
||||
|
||||
const isModalForThisFeedback =
|
||||
feedbackModalProps?.feedbackType === feedbackType;
|
||||
const isModalForThisMessage = feedbackModalProps?.messageId === messageId;
|
||||
|
||||
return (
|
||||
hasCurrentFeedback || (isModalForThisFeedback && isModalForThisMessage)
|
||||
);
|
||||
},
|
||||
[currentFeedback, modal.isOpen, feedbackModalProps, messageId]
|
||||
);
|
||||
|
||||
// Handler for feedback button clicks with toggle logic
|
||||
const handleFeedbackClick = useCallback(
|
||||
async (clickedFeedback: "like" | "dislike") => {
|
||||
if (!messageId) {
|
||||
console.error("Cannot provide feedback - message has no messageId");
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle logic
|
||||
if (currentFeedback === clickedFeedback) {
|
||||
// Clicking same button - remove feedback
|
||||
await handleFeedbackChange(messageId, null);
|
||||
}
|
||||
|
||||
// Clicking like (will automatically clear dislike if it was active).
|
||||
// Check if we need modal for positive feedback.
|
||||
else if (clickedFeedback === "like") {
|
||||
const predefinedOptions =
|
||||
process.env.NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS;
|
||||
if (predefinedOptions && predefinedOptions.trim()) {
|
||||
// Open modal for positive feedback
|
||||
setFeedbackModalProps({
|
||||
feedbackType: "like",
|
||||
messageId,
|
||||
});
|
||||
modal.toggle(true);
|
||||
} else {
|
||||
// No modal needed - just submit like (this replaces any existing feedback)
|
||||
await handleFeedbackChange(messageId, "like");
|
||||
}
|
||||
}
|
||||
|
||||
// Clicking dislike (will automatically clear like if it was active).
|
||||
// Always open modal for dislike.
|
||||
else {
|
||||
setFeedbackModalProps({
|
||||
feedbackType: "dislike",
|
||||
messageId,
|
||||
});
|
||||
modal.toggle(true);
|
||||
}
|
||||
},
|
||||
[messageId, currentFeedback, handleFeedbackChange, modal]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{popup}
|
||||
|
||||
<modal.Provider>
|
||||
<FeedbackModal {...feedbackModalProps!} />
|
||||
</modal.Provider>
|
||||
|
||||
<div className="flex md:flex-row justify-between items-center w-full transition-transform duration-300 ease-in-out transform opacity-100">
|
||||
<TooltipGroup>
|
||||
<div className="flex items-center gap-x-0.5">
|
||||
{includeMessageSwitcher && (
|
||||
<div className="-mx-1">
|
||||
<MessageSwitcher
|
||||
currentPage={(currentMessageInd ?? 0) + 1}
|
||||
totalPages={otherMessagesCanSwitchTo?.length || 0}
|
||||
handlePrevious={() => {
|
||||
const prevMessage = getPreviousMessage();
|
||||
if (prevMessage !== undefined && onMessageSelection) {
|
||||
onMessageSelection(prevMessage);
|
||||
}
|
||||
}}
|
||||
handleNext={() => {
|
||||
const nextMessage = getNextMessage();
|
||||
if (nextMessage !== undefined && onMessageSelection) {
|
||||
onMessageSelection(nextMessage);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CopyIconButton
|
||||
getCopyText={() =>
|
||||
convertMarkdownTablesToTsv(
|
||||
removeThinkingTokens(getTextContent(rawPackets)) as string
|
||||
)
|
||||
}
|
||||
getHtmlContent={() => finalAnswerRef.current?.innerHTML || ""}
|
||||
tertiary
|
||||
data-testid="AgentMessage/copy-button"
|
||||
/>
|
||||
<IconButton
|
||||
icon={SvgThumbsUp}
|
||||
onClick={() => handleFeedbackClick("like")}
|
||||
tertiary
|
||||
transient={isFeedbackTransient("like")}
|
||||
tooltip={
|
||||
currentFeedback === "like" ? "Remove Like" : "Good Response"
|
||||
}
|
||||
data-testid="AgentMessage/like-button"
|
||||
/>
|
||||
<IconButton
|
||||
icon={SvgThumbsDown}
|
||||
onClick={() => handleFeedbackClick("dislike")}
|
||||
tertiary
|
||||
transient={isFeedbackTransient("dislike")}
|
||||
tooltip={
|
||||
currentFeedback === "dislike"
|
||||
? "Remove Dislike"
|
||||
: "Bad Response"
|
||||
}
|
||||
data-testid="AgentMessage/dislike-button"
|
||||
/>
|
||||
|
||||
{onRegenerate &&
|
||||
messageId !== undefined &&
|
||||
parentMessage &&
|
||||
llmManager && (
|
||||
<div data-testid="AgentMessage/regenerate">
|
||||
<LLMPopover
|
||||
llmManager={llmManager}
|
||||
currentModelName={currentModelName}
|
||||
onSelect={(modelName) => {
|
||||
const llmDescriptor = parseLlmDescriptor(modelName);
|
||||
const regenerator = onRegenerate({
|
||||
messageId,
|
||||
parentMessage,
|
||||
});
|
||||
regenerator(llmDescriptor);
|
||||
}}
|
||||
folded
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nodeId && (citations.length > 0 || documentMap.size > 0) && (
|
||||
<SourcesTagWrapper
|
||||
citations={citations}
|
||||
documentMap={documentMap}
|
||||
nodeId={nodeId}
|
||||
selectedMessageForDocDisplay={selectedMessageForDocDisplay}
|
||||
documentSidebarVisible={documentSidebarVisible}
|
||||
updateCurrentDocumentSidebarVisible={
|
||||
updateCurrentDocumentSidebarVisible
|
||||
}
|
||||
updateCurrentSelectedNodeForDocDisplay={
|
||||
updateCurrentSelectedNodeForDocDisplay
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TooltipGroup>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Packet } from "@/app/chat/services/streamingModels";
|
||||
import { Packet } from "@/app/app/services/streamingModels";
|
||||
|
||||
// Control the rate of packet streaming (packets per second)
|
||||
const PACKET_DELAY_MS = 10;
|
||||
@@ -12,6 +12,7 @@ export enum RenderType {
|
||||
HIGHLIGHT = "highlight",
|
||||
FULL = "full",
|
||||
COMPACT = "compact",
|
||||
INLINE = "inline",
|
||||
}
|
||||
|
||||
export interface FullChatState {
|
||||
@@ -37,10 +38,13 @@ export interface RendererResult {
|
||||
// e.g. ReasoningRenderer
|
||||
expandedText?: JSX.Element;
|
||||
|
||||
// Whether this renderer supports compact mode (collapse button shown only when true)
|
||||
supportsCompact?: boolean;
|
||||
// Whether this renderer supports collapsible mode (collapse button shown only when true)
|
||||
supportsCollapsible?: boolean;
|
||||
}
|
||||
|
||||
// All renderers return an array of results (even single-step renderers return a 1-element array)
|
||||
export type RendererOutput = RendererResult[];
|
||||
|
||||
export type MessageRenderer<
|
||||
T extends Packet,
|
||||
S extends Partial<FullChatState>,
|
||||
@@ -54,5 +58,7 @@ export type MessageRenderer<
|
||||
stopReason?: StopReason;
|
||||
/** Whether this is the last step in the timeline (for connector line decisions) */
|
||||
isLastStep?: boolean;
|
||||
children: (result: RendererResult) => JSX.Element;
|
||||
/** Hover state from parent */
|
||||
isHover?: boolean;
|
||||
children: (result: RendererOutput) => JSX.Element;
|
||||
}>;
|
||||
@@ -6,13 +6,13 @@ import rehypeHighlight from "rehype-highlight";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "@/app/chat/message/custom-code-styles.css";
|
||||
import { FullChatState } from "@/app/chat/message/messageComponents/interfaces";
|
||||
import { FullChatState } from "@/app/app/message/messageComponents/interfaces";
|
||||
import {
|
||||
MemoizedAnchor,
|
||||
MemoizedParagraph,
|
||||
} from "@/app/chat/message/MemoizedTextComponents";
|
||||
import { extractCodeText, preprocessLaTeX } from "@/app/chat/message/codeUtils";
|
||||
import { CodeBlock } from "@/app/chat/message/CodeBlock";
|
||||
} from "@/app/app/message/MemoizedTextComponents";
|
||||
import { extractCodeText, preprocessLaTeX } from "@/app/app/message/codeUtils";
|
||||
import { CodeBlock } from "@/app/app/message/CodeBlock";
|
||||
import { transformLinkUri, cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { JSX } from "react";
|
||||
import React, { JSX, memo } from "react";
|
||||
import {
|
||||
ChatPacket,
|
||||
Packet,
|
||||
@@ -11,16 +11,19 @@ import {
|
||||
MessageRenderer,
|
||||
RenderType,
|
||||
RendererResult,
|
||||
RendererOutput,
|
||||
} from "./interfaces";
|
||||
import { MessageTextRenderer } from "./renderers/MessageTextRenderer";
|
||||
import { ImageToolRenderer } from "./renderers/ImageToolRenderer";
|
||||
import { PythonToolRenderer } from "./renderers/PythonToolRenderer";
|
||||
import { ReasoningRenderer } from "./renderers/ReasoningRenderer";
|
||||
import { PythonToolRenderer } from "./timeline/renderers/code/PythonToolRenderer";
|
||||
import { ReasoningRenderer } from "./timeline/renderers/reasoning/ReasoningRenderer";
|
||||
import CustomToolRenderer from "./renderers/CustomToolRenderer";
|
||||
import { FetchToolRenderer } from "./renderers/FetchToolRenderer";
|
||||
import { DeepResearchPlanRenderer } from "./renderers/DeepResearchPlanRenderer";
|
||||
import { ResearchAgentRenderer } from "./renderers/ResearchAgentRenderer";
|
||||
import { SearchToolRenderer } from "./renderers/SearchToolRenderer";
|
||||
import { FetchToolRenderer } from "./timeline/renderers/fetch/FetchToolRenderer";
|
||||
import { DeepResearchPlanRenderer } from "./timeline/renderers/deepresearch/DeepResearchPlanRenderer";
|
||||
import { ResearchAgentRenderer } from "./timeline/renderers/deepresearch/ResearchAgentRenderer";
|
||||
import { WebSearchToolRenderer } from "./timeline/renderers/search/WebSearchToolRenderer";
|
||||
import { InternalSearchToolRenderer } from "./timeline/renderers/search/InternalSearchToolRenderer";
|
||||
import { SearchToolStart } from "../../services/streamingModels";
|
||||
|
||||
// Different types of chat packets using discriminated unions
|
||||
export interface GroupedPackets {
|
||||
@@ -35,8 +38,14 @@ function isChatPacket(packet: Packet): packet is ChatPacket {
|
||||
);
|
||||
}
|
||||
|
||||
function isSearchToolPacket(packet: Packet) {
|
||||
return packet.obj.type === PacketType.SEARCH_TOOL_START;
|
||||
function isWebSearchPacket(packet: Packet): boolean {
|
||||
if (packet.obj.type !== PacketType.SEARCH_TOOL_START) return false;
|
||||
return (packet.obj as SearchToolStart).is_internet_search === true;
|
||||
}
|
||||
|
||||
function isInternalSearchPacket(packet: Packet): boolean {
|
||||
if (packet.obj.type !== PacketType.SEARCH_TOOL_START) return false;
|
||||
return (packet.obj as SearchToolStart).is_internet_search !== true;
|
||||
}
|
||||
|
||||
function isImageToolPacket(packet: Packet) {
|
||||
@@ -101,8 +110,11 @@ export function findRenderer(
|
||||
}
|
||||
|
||||
// Standard tool checks
|
||||
if (groupedPackets.packets.some((packet) => isSearchToolPacket(packet))) {
|
||||
return SearchToolRenderer;
|
||||
if (groupedPackets.packets.some((packet) => isWebSearchPacket(packet))) {
|
||||
return WebSearchToolRenderer;
|
||||
}
|
||||
if (groupedPackets.packets.some((packet) => isInternalSearchPacket(packet))) {
|
||||
return InternalSearchToolRenderer;
|
||||
}
|
||||
if (groupedPackets.packets.some((packet) => isImageToolPacket(packet))) {
|
||||
return ImageToolRenderer;
|
||||
@@ -122,8 +134,36 @@ export function findRenderer(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Props interface for RendererComponent
|
||||
interface RendererComponentProps {
|
||||
packets: Packet[];
|
||||
chatState: FullChatState;
|
||||
onComplete: () => void;
|
||||
animate: boolean;
|
||||
stopPacketSeen: boolean;
|
||||
stopReason?: StopReason;
|
||||
useShortRenderer?: boolean;
|
||||
children: (result: RendererOutput) => JSX.Element;
|
||||
}
|
||||
|
||||
// Custom comparison to prevent unnecessary re-renders
|
||||
function areRendererPropsEqual(
|
||||
prev: RendererComponentProps,
|
||||
next: RendererComponentProps
|
||||
): boolean {
|
||||
return (
|
||||
prev.packets.length === next.packets.length &&
|
||||
prev.stopPacketSeen === next.stopPacketSeen &&
|
||||
prev.stopReason === next.stopReason &&
|
||||
prev.animate === next.animate &&
|
||||
prev.useShortRenderer === next.useShortRenderer &&
|
||||
prev.chatState.assistant?.id === next.chatState.assistant?.id
|
||||
// Skip: onComplete, children (function refs), chatState (memoized upstream)
|
||||
);
|
||||
}
|
||||
|
||||
// React component wrapper that directly uses renderer components
|
||||
export function RendererComponent({
|
||||
export const RendererComponent = memo(function RendererComponent({
|
||||
packets,
|
||||
chatState,
|
||||
onComplete,
|
||||
@@ -132,21 +172,12 @@ export function RendererComponent({
|
||||
stopReason,
|
||||
useShortRenderer = false,
|
||||
children,
|
||||
}: {
|
||||
packets: Packet[];
|
||||
chatState: FullChatState;
|
||||
onComplete: () => void;
|
||||
animate: boolean;
|
||||
stopPacketSeen: boolean;
|
||||
stopReason?: StopReason;
|
||||
useShortRenderer?: boolean;
|
||||
children: (result: RendererResult) => JSX.Element;
|
||||
}) {
|
||||
}: RendererComponentProps) {
|
||||
const RendererFn = findRenderer({ packets });
|
||||
const renderType = useShortRenderer ? RenderType.HIGHLIGHT : RenderType.FULL;
|
||||
|
||||
if (!RendererFn) {
|
||||
return children({ icon: null, status: null, content: <></> });
|
||||
return children([{ icon: null, status: null, content: <></> }]);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -159,7 +190,7 @@ export function RendererComponent({
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
>
|
||||
{children}
|
||||
{(results: RendererOutput) => children(results)}
|
||||
</RendererFn>
|
||||
);
|
||||
}
|
||||
}, areRendererPropsEqual);
|
||||
@@ -69,67 +69,71 @@ export const CustomToolRenderer: MessageRenderer<CustomToolPacket, {}> = ({
|
||||
const icon = FiTool;
|
||||
|
||||
if (renderType === RenderType.COMPACT) {
|
||||
return children({
|
||||
icon,
|
||||
status: status,
|
||||
supportsCompact: true,
|
||||
content: (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isRunning && `${toolName} running...`}
|
||||
{isComplete && `${toolName} completed`}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
return children([
|
||||
{
|
||||
icon,
|
||||
status: status,
|
||||
supportsCollapsible: true,
|
||||
content: (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isRunning && `${toolName} running...`}
|
||||
{isComplete && `${toolName} completed`}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return children({
|
||||
icon,
|
||||
status,
|
||||
supportsCompact: true,
|
||||
content: (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* File responses */}
|
||||
{fileIds && fileIds.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground flex flex-col gap-2">
|
||||
{fileIds.map((fid, idx) => (
|
||||
<div key={fid} className="flex items-center gap-2 flex-wrap">
|
||||
<span className="whitespace-nowrap">File {idx + 1}</span>
|
||||
<a
|
||||
href={buildImgUrl(fid)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
<FiExternalLink className="w-3 h-3" /> Open
|
||||
</a>
|
||||
<a
|
||||
href={buildImgUrl(fid)}
|
||||
download
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
<FiDownload className="w-3 h-3" /> Download
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
return children([
|
||||
{
|
||||
icon,
|
||||
status,
|
||||
supportsCollapsible: true,
|
||||
content: (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* File responses */}
|
||||
{fileIds && fileIds.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground flex flex-col gap-2">
|
||||
{fileIds.map((fid, idx) => (
|
||||
<div key={fid} className="flex items-center gap-2 flex-wrap">
|
||||
<span className="whitespace-nowrap">File {idx + 1}</span>
|
||||
<a
|
||||
href={buildImgUrl(fid)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
<FiExternalLink className="w-3 h-3" /> Open
|
||||
</a>
|
||||
<a
|
||||
href={buildImgUrl(fid)}
|
||||
download
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
<FiDownload className="w-3 h-3" /> Download
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON/Text responses */}
|
||||
{data !== undefined && data !== null && (
|
||||
<div className="text-xs bg-gray-50 dark:bg-gray-800 p-3 rounded border max-h-96 overflow-y-auto font-mono whitespace-pre-wrap break-all">
|
||||
{typeof data === "string" ? data : JSON.stringify(data, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
{/* JSON/Text responses */}
|
||||
{data !== undefined && data !== null && (
|
||||
<div className="text-xs bg-gray-50 dark:bg-gray-800 p-3 rounded border max-h-96 overflow-y-auto font-mono whitespace-pre-wrap break-all">
|
||||
{typeof data === "string" ? data : JSON.stringify(data, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show placeholder if no response data yet */}
|
||||
{!fileIds && (data === undefined || data === null) && isRunning && (
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
Waiting for response...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
});
|
||||
{/* Show placeholder if no response data yet */}
|
||||
{!fileIds && (data === undefined || data === null) && isRunning && (
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
Waiting for response...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
export default CustomToolRenderer;
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { SvgImage } from "@opal/icons";
|
||||
import {
|
||||
PacketType,
|
||||
ImageGenerationToolPacket,
|
||||
ImageGenerationToolStart,
|
||||
ImageGenerationToolDelta,
|
||||
SectionEnd,
|
||||
} from "../../../services/streamingModels";
|
||||
import { MessageRenderer, RenderType } from "../interfaces";
|
||||
import { InMessageImage } from "../../../components/files/images/InMessageImage";
|
||||
import GeneratingImageDisplay from "../../../components/tools/GeneratingImageDisplay";
|
||||
|
||||
// Helper function to construct current image state
|
||||
function constructCurrentImageState(packets: ImageGenerationToolPacket[]) {
|
||||
const imageStart = packets.find(
|
||||
(packet) => packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START
|
||||
)?.obj as ImageGenerationToolStart | null;
|
||||
const imageDeltas = packets
|
||||
.filter(
|
||||
(packet) => packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_DELTA
|
||||
)
|
||||
.map((packet) => packet.obj as ImageGenerationToolDelta);
|
||||
const imageEnd = packets.find(
|
||||
(packet) =>
|
||||
packet.obj.type === PacketType.SECTION_END ||
|
||||
packet.obj.type === PacketType.ERROR
|
||||
)?.obj as SectionEnd | null;
|
||||
|
||||
const prompt = ""; // Image generation tools don't have a main description
|
||||
const images = imageDeltas.flatMap((delta) => delta?.images || []);
|
||||
const isGenerating = imageStart && !imageEnd;
|
||||
const isComplete = imageStart && imageEnd;
|
||||
|
||||
return {
|
||||
prompt,
|
||||
images,
|
||||
isGenerating,
|
||||
isComplete,
|
||||
error: false, // For now, we don't have error state in the packets
|
||||
};
|
||||
}
|
||||
|
||||
export const ImageToolRenderer: MessageRenderer<
|
||||
ImageGenerationToolPacket,
|
||||
{}
|
||||
> = ({ packets, onComplete, renderType, children }) => {
|
||||
const { prompt, images, isGenerating, isComplete, error } =
|
||||
constructCurrentImageState(packets);
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}, [isComplete]);
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (isComplete) {
|
||||
return `Generated ${images.length} image${images.length > 1 ? "s" : ""}`;
|
||||
}
|
||||
if (isGenerating) {
|
||||
return "Generating image...";
|
||||
}
|
||||
return null;
|
||||
}, [isComplete, isGenerating, images.length]);
|
||||
|
||||
// Render based on renderType
|
||||
if (renderType === RenderType.FULL) {
|
||||
// Full rendering with title header and content below
|
||||
// Loading state - when generating
|
||||
if (isGenerating) {
|
||||
return children([
|
||||
{
|
||||
icon: SvgImage,
|
||||
status: "Generating images...",
|
||||
supportsCollapsible: false,
|
||||
content: (
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
<GeneratingImageDisplay isCompleted={false} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Complete state - show images
|
||||
if (isComplete) {
|
||||
return children([
|
||||
{
|
||||
icon: SvgImage,
|
||||
status: `Generated ${images.length} image${
|
||||
images.length !== 1 ? "s" : ""
|
||||
}`,
|
||||
supportsCollapsible: false,
|
||||
content: (
|
||||
<div className="flex flex-col my-1">
|
||||
{images.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{images.map((image, index: number) => (
|
||||
<div
|
||||
key={image.file_id || index}
|
||||
className="transition-all group"
|
||||
>
|
||||
{image.file_id && (
|
||||
<InMessageImage
|
||||
fileId={image.file_id}
|
||||
shape={image.shape}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-gray-500 dark:text-gray-400 ml-7">
|
||||
<SvgImage className="w-6 h-6 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No images generated</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Fallback (shouldn't happen in normal flow)
|
||||
return children([
|
||||
{
|
||||
icon: SvgImage,
|
||||
status: status,
|
||||
supportsCollapsible: false,
|
||||
content: <div></div>,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Highlight/Short rendering
|
||||
if (isGenerating) {
|
||||
return children([
|
||||
{
|
||||
icon: SvgImage,
|
||||
status: "Generating image...",
|
||||
supportsCollapsible: false,
|
||||
content: (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex gap-0.5">
|
||||
<div className="w-1 h-1 bg-current rounded-full animate-pulse"></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-current rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.1s" }}
|
||||
></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-current rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
></div>
|
||||
</div>
|
||||
<span>Generating image...</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return children([
|
||||
{
|
||||
icon: SvgImage,
|
||||
status: "Image generation failed",
|
||||
supportsCollapsible: false,
|
||||
content: (
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
Image generation failed
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (isComplete && images.length > 0) {
|
||||
return children([
|
||||
{
|
||||
icon: SvgImage,
|
||||
status: `Generated ${images.length} image${
|
||||
images.length > 1 ? "s" : ""
|
||||
}`,
|
||||
supportsCollapsible: false,
|
||||
content: (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Generated {images.length} image
|
||||
{images.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return children([
|
||||
{
|
||||
icon: SvgImage,
|
||||
status: "Image generation",
|
||||
supportsCollapsible: false,
|
||||
content: (
|
||||
<div className="text-sm text-muted-foreground">Image generation</div>
|
||||
),
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -123,21 +123,23 @@ export const MessageTextRenderer: MessageRenderer<
|
||||
|
||||
const wasUserCancelled = stopReason === StopReason.USER_CANCELLED;
|
||||
|
||||
return children({
|
||||
icon: null,
|
||||
status: null,
|
||||
content:
|
||||
content.length > 0 || packets.length > 0 ? (
|
||||
<>
|
||||
{renderedContent}
|
||||
{wasUserCancelled && (
|
||||
<Text as="p" secondaryBody text04>
|
||||
User has stopped generation
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<BlinkingDot addMargin />
|
||||
),
|
||||
});
|
||||
return children([
|
||||
{
|
||||
icon: null,
|
||||
status: null,
|
||||
content:
|
||||
content.length > 0 || packets.length > 0 ? (
|
||||
<>
|
||||
{renderedContent}
|
||||
{wasUserCancelled && (
|
||||
<Text as="p" secondaryBody text04>
|
||||
User has stopped generation
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<BlinkingDot addMargin />
|
||||
),
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useCallback } from "react";
|
||||
import { StopReason } from "@/app/app/services/streamingModels";
|
||||
import { FullChatState, RenderType } from "../interfaces";
|
||||
import { TurnGroup } from "./transformers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import AgentAvatar from "@/refresh-components/avatars/AgentAvatar";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { useTimelineExpansion } from "@/app/app/message/messageComponents/timeline/hooks/useTimelineExpansion";
|
||||
import { useTimelineMetrics } from "@/app/app/message/messageComponents/timeline/hooks/useTimelineMetrics";
|
||||
import { useTimelineHeader } from "@/app/app/message/messageComponents/timeline/hooks/useTimelineHeader";
|
||||
import {
|
||||
useTimelineUIState,
|
||||
TimelineUIState,
|
||||
} from "@/app/app/message/messageComponents/timeline/hooks/useTimelineUIState";
|
||||
import {
|
||||
isResearchAgentPackets,
|
||||
isSearchToolPackets,
|
||||
stepSupportsCollapsedStreaming,
|
||||
} from "@/app/app/message/messageComponents/timeline/packetHelpers";
|
||||
import { StreamingHeader } from "@/app/app/message/messageComponents/timeline/headers/StreamingHeader";
|
||||
import { CollapsedHeader } from "@/app/app/message/messageComponents/timeline/headers/CollapsedHeader";
|
||||
import { ExpandedHeader } from "@/app/app/message/messageComponents/timeline/headers/ExpandedHeader";
|
||||
import { StoppedHeader } from "@/app/app/message/messageComponents/timeline/headers/StoppedHeader";
|
||||
import { ParallelStreamingHeader } from "@/app/app/message/messageComponents/timeline/headers/ParallelStreamingHeader";
|
||||
import { useStreamingStartTime } from "@/app/app/stores/useChatSessionStore";
|
||||
import { ExpandedTimelineContent } from "./ExpandedTimelineContent";
|
||||
import { CollapsedStreamingContent } from "./CollapsedStreamingContent";
|
||||
|
||||
// =============================================================================
|
||||
// Private Wrapper Components
|
||||
// =============================================================================
|
||||
|
||||
interface TimelineContainerProps {
|
||||
className?: string;
|
||||
agent: FullChatState["assistant"];
|
||||
headerContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const TimelineContainer: React.FC<TimelineContainerProps> = ({
|
||||
className,
|
||||
agent,
|
||||
headerContent,
|
||||
children,
|
||||
}) => (
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
<div className="flex w-full h-9">
|
||||
<div className="flex justify-center items-center size-9">
|
||||
<AgentAvatar agent={agent} size={24} />
|
||||
</div>
|
||||
{headerContent}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export interface AgentTimelineProps {
|
||||
/** Turn groups from usePacketProcessor */
|
||||
turnGroups: TurnGroup[];
|
||||
/** Chat state for rendering content */
|
||||
chatState: FullChatState;
|
||||
/** Whether the stop packet has been seen */
|
||||
stopPacketSeen?: boolean;
|
||||
/** Reason for stopping (if stopped) */
|
||||
stopReason?: StopReason;
|
||||
/** Whether final answer is coming (affects last connector) */
|
||||
finalAnswerComing?: boolean;
|
||||
/** Whether there is display content after timeline */
|
||||
hasDisplayContent?: boolean;
|
||||
/** Content to render after timeline (final message + toolbar) - slot pattern */
|
||||
children?: React.ReactNode;
|
||||
/** Whether the timeline is collapsible */
|
||||
collapsible?: boolean;
|
||||
/** Title of the button to toggle the timeline */
|
||||
buttonTitle?: string;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
/** Test ID for e2e testing */
|
||||
"data-testid"?: string;
|
||||
/** Processing duration in seconds (for completed messages) */
|
||||
processingDurationSeconds?: number;
|
||||
/** Whether image generation is in progress */
|
||||
isGeneratingImage?: boolean;
|
||||
/** Number of images generated */
|
||||
generatedImageCount?: number;
|
||||
/** Tool processing duration from backend (via MESSAGE_START packet) */
|
||||
toolProcessingDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom prop comparison for AgentTimeline memoization.
|
||||
* Prevents unnecessary re-renders when parent renders but props haven't meaningfully changed.
|
||||
*/
|
||||
function areAgentTimelinePropsEqual(
|
||||
prev: AgentTimelineProps,
|
||||
next: AgentTimelineProps
|
||||
): boolean {
|
||||
return (
|
||||
prev.turnGroups === next.turnGroups &&
|
||||
prev.stopPacketSeen === next.stopPacketSeen &&
|
||||
prev.stopReason === next.stopReason &&
|
||||
prev.finalAnswerComing === next.finalAnswerComing &&
|
||||
prev.hasDisplayContent === next.hasDisplayContent &&
|
||||
prev.processingDurationSeconds === next.processingDurationSeconds &&
|
||||
prev.collapsible === next.collapsible &&
|
||||
prev.buttonTitle === next.buttonTitle &&
|
||||
prev.className === next.className &&
|
||||
prev.chatState.assistant?.id === next.chatState.assistant?.id &&
|
||||
prev.isGeneratingImage === next.isGeneratingImage &&
|
||||
prev.generatedImageCount === next.generatedImageCount &&
|
||||
prev.toolProcessingDuration === next.toolProcessingDuration
|
||||
);
|
||||
}
|
||||
|
||||
export const AgentTimeline = React.memo(function AgentTimeline({
|
||||
turnGroups,
|
||||
chatState,
|
||||
stopPacketSeen = false,
|
||||
stopReason,
|
||||
finalAnswerComing = false,
|
||||
hasDisplayContent = false,
|
||||
collapsible = true,
|
||||
buttonTitle,
|
||||
className,
|
||||
"data-testid": testId,
|
||||
processingDurationSeconds,
|
||||
isGeneratingImage = false,
|
||||
generatedImageCount = 0,
|
||||
toolProcessingDuration,
|
||||
}: AgentTimelineProps) {
|
||||
// Header text and state flags
|
||||
const { headerText, hasPackets, userStopped } = useTimelineHeader(
|
||||
turnGroups,
|
||||
stopReason,
|
||||
isGeneratingImage
|
||||
);
|
||||
|
||||
// Memoized metrics derived from turn groups
|
||||
const {
|
||||
totalSteps,
|
||||
isSingleStep,
|
||||
lastTurnGroup,
|
||||
lastStep,
|
||||
lastStepIsResearchAgent,
|
||||
lastStepSupportsCollapsedStreaming,
|
||||
} = useTimelineMetrics(turnGroups, userStopped);
|
||||
|
||||
// Check if last step is a search tool for INLINE render type
|
||||
const lastStepIsSearchTool = useMemo(
|
||||
() => lastStep && isSearchToolPackets(lastStep.packets),
|
||||
[lastStep]
|
||||
);
|
||||
|
||||
const { isExpanded, handleToggle, parallelActiveTab, setParallelActiveTab } =
|
||||
useTimelineExpansion(stopPacketSeen, lastTurnGroup, hasDisplayContent);
|
||||
|
||||
// Streaming duration tracking
|
||||
const streamingStartTime = useStreamingStartTime();
|
||||
|
||||
// Parallel step analysis for collapsed streaming view
|
||||
const parallelActiveStep = useMemo(() => {
|
||||
if (!lastTurnGroup?.isParallel) return null;
|
||||
return (
|
||||
lastTurnGroup.steps.find((s) => s.key === parallelActiveTab) ??
|
||||
lastTurnGroup.steps[0]
|
||||
);
|
||||
}, [lastTurnGroup, parallelActiveTab]);
|
||||
|
||||
const parallelActiveStepSupportsCollapsedStreaming = useMemo(() => {
|
||||
if (!parallelActiveStep) return false;
|
||||
return stepSupportsCollapsedStreaming(parallelActiveStep.packets);
|
||||
}, [parallelActiveStep]);
|
||||
|
||||
// Derive all UI state from inputs
|
||||
const {
|
||||
uiState,
|
||||
showCollapsedCompact,
|
||||
showCollapsedParallel,
|
||||
showParallelTabs,
|
||||
showDoneStep,
|
||||
showStoppedStep,
|
||||
hasDoneIndicator,
|
||||
showTintedBackground,
|
||||
showRoundedBottom,
|
||||
} = useTimelineUIState({
|
||||
stopPacketSeen,
|
||||
hasPackets,
|
||||
hasDisplayContent,
|
||||
userStopped,
|
||||
isExpanded,
|
||||
lastTurnGroup,
|
||||
lastStep,
|
||||
lastStepSupportsCollapsedStreaming,
|
||||
lastStepIsResearchAgent,
|
||||
parallelActiveStepSupportsCollapsedStreaming,
|
||||
isGeneratingImage,
|
||||
finalAnswerComing,
|
||||
});
|
||||
|
||||
// Determine render type override for collapsed streaming view
|
||||
const collapsedRenderTypeOverride = useMemo(() => {
|
||||
if (lastStepIsResearchAgent) return RenderType.HIGHLIGHT;
|
||||
if (lastStepIsSearchTool) return RenderType.INLINE;
|
||||
return undefined;
|
||||
}, [lastStepIsResearchAgent, lastStepIsSearchTool]);
|
||||
|
||||
// Header selection based on UI state
|
||||
const renderHeader = useCallback(() => {
|
||||
switch (uiState) {
|
||||
case TimelineUIState.STREAMING_PARALLEL:
|
||||
// Only show parallel header when collapsed (showParallelTabs includes !isExpanded check)
|
||||
if (showParallelTabs && lastTurnGroup) {
|
||||
return (
|
||||
<ParallelStreamingHeader
|
||||
steps={lastTurnGroup.steps}
|
||||
activeTab={parallelActiveTab}
|
||||
onTabChange={setParallelActiveTab}
|
||||
collapsible={collapsible}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// falls through to sequential header when expanded or no lastTurnGroup
|
||||
case TimelineUIState.STREAMING_SEQUENTIAL:
|
||||
return (
|
||||
<StreamingHeader
|
||||
headerText={headerText}
|
||||
collapsible={collapsible}
|
||||
buttonTitle={buttonTitle}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
streamingStartTime={streamingStartTime}
|
||||
toolProcessingDuration={toolProcessingDuration}
|
||||
/>
|
||||
);
|
||||
|
||||
case TimelineUIState.STOPPED:
|
||||
return (
|
||||
<StoppedHeader
|
||||
totalSteps={totalSteps}
|
||||
collapsible={collapsible}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
);
|
||||
|
||||
case TimelineUIState.COMPLETED_COLLAPSED:
|
||||
return (
|
||||
<CollapsedHeader
|
||||
totalSteps={totalSteps}
|
||||
collapsible={collapsible}
|
||||
onToggle={handleToggle}
|
||||
processingDurationSeconds={
|
||||
toolProcessingDuration ?? processingDurationSeconds
|
||||
}
|
||||
generatedImageCount={generatedImageCount}
|
||||
/>
|
||||
);
|
||||
|
||||
case TimelineUIState.COMPLETED_EXPANDED:
|
||||
return (
|
||||
<ExpandedHeader
|
||||
collapsible={collapsible}
|
||||
onToggle={handleToggle}
|
||||
processingDurationSeconds={
|
||||
toolProcessingDuration ?? processingDurationSeconds
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
uiState,
|
||||
showParallelTabs,
|
||||
lastTurnGroup,
|
||||
parallelActiveTab,
|
||||
setParallelActiveTab,
|
||||
collapsible,
|
||||
isExpanded,
|
||||
handleToggle,
|
||||
headerText,
|
||||
buttonTitle,
|
||||
streamingStartTime,
|
||||
totalSteps,
|
||||
processingDurationSeconds,
|
||||
generatedImageCount,
|
||||
toolProcessingDuration,
|
||||
]);
|
||||
|
||||
// Empty state: no packets, still streaming, and not stopped
|
||||
if (uiState === TimelineUIState.EMPTY) {
|
||||
return (
|
||||
<TimelineContainer
|
||||
className={className}
|
||||
agent={chatState.assistant}
|
||||
headerContent={
|
||||
<div className="flex w-full h-full items-center px-2">
|
||||
<Text
|
||||
as="p"
|
||||
mainUiAction
|
||||
text03
|
||||
className="animate-shimmer bg-[length:200%_100%] bg-[linear-gradient(90deg,var(--shimmer-base)_10%,var(--shimmer-highlight)_40%,var(--shimmer-base)_70%)] bg-clip-text text-transparent"
|
||||
>
|
||||
{headerText}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Display content only (no timeline steps) - but show header for image generation
|
||||
if (uiState === TimelineUIState.DISPLAY_CONTENT_ONLY) {
|
||||
return (
|
||||
<TimelineContainer className={className} agent={chatState.assistant} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContainer
|
||||
className={className}
|
||||
agent={chatState.assistant}
|
||||
headerContent={
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full min-w-0 h-full items-center justify-between pl-2 pr-1 transition-colors duration-300",
|
||||
showTintedBackground && "bg-background-tint-00 rounded-t-12",
|
||||
showRoundedBottom && "rounded-b-12"
|
||||
)}
|
||||
>
|
||||
{renderHeader()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Collapsed streaming view - single step compact mode */}
|
||||
{showCollapsedCompact && lastStep && (
|
||||
<CollapsedStreamingContent
|
||||
step={lastStep}
|
||||
chatState={chatState}
|
||||
stopReason={stopReason}
|
||||
renderTypeOverride={collapsedRenderTypeOverride}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Collapsed streaming view - parallel tools compact mode */}
|
||||
{showCollapsedParallel && parallelActiveStep && (
|
||||
<CollapsedStreamingContent
|
||||
step={parallelActiveStep}
|
||||
chatState={chatState}
|
||||
stopReason={stopReason}
|
||||
renderTypeOverride={RenderType.HIGHLIGHT}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expanded timeline view */}
|
||||
{isExpanded && (
|
||||
<ExpandedTimelineContent
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
isSingleStep={isSingleStep}
|
||||
userStopped={userStopped}
|
||||
showDoneStep={showDoneStep}
|
||||
showStoppedStep={showStoppedStep}
|
||||
hasDoneIndicator={hasDoneIndicator}
|
||||
/>
|
||||
)}
|
||||
</TimelineContainer>
|
||||
);
|
||||
}, areAgentTimelinePropsEqual);
|
||||
|
||||
export default AgentTimeline;
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { StopReason } from "@/app/app/services/streamingModels";
|
||||
import { FullChatState, RenderType } from "../interfaces";
|
||||
import { TransformedStep } from "./transformers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
TimelineRendererComponent,
|
||||
TimelineRendererOutput,
|
||||
} from "./TimelineRendererComponent";
|
||||
|
||||
// =============================================================================
|
||||
// TimelineContentRow - Layout helper for content rows
|
||||
// =============================================================================
|
||||
|
||||
interface TimelineContentRowProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const TimelineContentRow: React.FC<TimelineContentRowProps> = ({
|
||||
className,
|
||||
children,
|
||||
}) => (
|
||||
<div className="flex w-full">
|
||||
<div className="w-9" />
|
||||
<div className={cn("w-full", className)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// CollapsedStreamingContent Component
|
||||
// =============================================================================
|
||||
|
||||
export interface CollapsedStreamingContentProps {
|
||||
step: TransformedStep;
|
||||
chatState: FullChatState;
|
||||
stopReason?: StopReason;
|
||||
renderTypeOverride?: RenderType;
|
||||
}
|
||||
|
||||
export const CollapsedStreamingContent = React.memo(
|
||||
function CollapsedStreamingContent({
|
||||
step,
|
||||
chatState,
|
||||
stopReason,
|
||||
renderTypeOverride,
|
||||
}: CollapsedStreamingContentProps) {
|
||||
const noopComplete = useCallback(() => {}, []);
|
||||
const renderContentOnly = useCallback(
|
||||
(results: TimelineRendererOutput) => (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<React.Fragment key={index}>{result.content}</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineContentRow className="bg-background-tint-00 rounded-b-12 px-2 pb-2">
|
||||
<TimelineRendererComponent
|
||||
key={`${step.key}-compact`}
|
||||
packets={step.packets}
|
||||
chatState={chatState}
|
||||
onComplete={noopComplete}
|
||||
animate={true}
|
||||
stopPacketSeen={false}
|
||||
stopReason={stopReason}
|
||||
defaultExpanded={false}
|
||||
renderTypeOverride={renderTypeOverride}
|
||||
isLastStep={true}
|
||||
>
|
||||
{renderContentOnly}
|
||||
</TimelineRendererComponent>
|
||||
</TimelineContentRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default CollapsedStreamingContent;
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import React, { FunctionComponent, useMemo, useCallback } from "react";
|
||||
import { StopReason } from "@/app/app/services/streamingModels";
|
||||
import { FullChatState } from "../interfaces";
|
||||
import { TurnGroup, TransformedStep } from "./transformers";
|
||||
import { SvgCheckCircle, SvgStopCircle } from "@opal/icons";
|
||||
import { IconProps } from "@opal/types";
|
||||
import {
|
||||
TimelineRendererComponent,
|
||||
TimelineRendererResult,
|
||||
TimelineRendererOutput,
|
||||
} from "./TimelineRendererComponent";
|
||||
import { ParallelTimelineTabs } from "./ParallelTimelineTabs";
|
||||
import { StepContainer } from "./StepContainer";
|
||||
import {
|
||||
isResearchAgentPackets,
|
||||
isSearchToolPackets,
|
||||
isReasoningPackets,
|
||||
} from "@/app/app/message/messageComponents/timeline/packetHelpers";
|
||||
|
||||
// =============================================================================
|
||||
// TimelineStep Component - Memoized to prevent re-renders
|
||||
// =============================================================================
|
||||
|
||||
interface TimelineStepProps {
|
||||
step: TransformedStep;
|
||||
chatState: FullChatState;
|
||||
stopPacketSeen: boolean;
|
||||
stopReason?: StopReason;
|
||||
isLastStep: boolean;
|
||||
isFirstStep: boolean;
|
||||
isSingleStep: boolean;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
const noopCallback = () => {};
|
||||
|
||||
const TimelineStep = React.memo(function TimelineStep({
|
||||
step,
|
||||
chatState,
|
||||
stopPacketSeen,
|
||||
stopReason,
|
||||
isLastStep,
|
||||
isFirstStep,
|
||||
isSingleStep,
|
||||
isStreaming = false,
|
||||
}: TimelineStepProps) {
|
||||
const isResearchAgent = useMemo(
|
||||
() => isResearchAgentPackets(step.packets),
|
||||
[step.packets]
|
||||
);
|
||||
const isSearchTool = useMemo(
|
||||
() => isSearchToolPackets(step.packets),
|
||||
[step.packets]
|
||||
);
|
||||
const isReasoning = useMemo(
|
||||
() => isReasoningPackets(step.packets),
|
||||
[step.packets]
|
||||
);
|
||||
|
||||
const renderStep = useCallback(
|
||||
(results: TimelineRendererOutput) => {
|
||||
if (isResearchAgent) {
|
||||
return (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<React.Fragment key={index}>{result.content}</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<StepContainer
|
||||
key={index}
|
||||
stepIcon={result.icon as FunctionComponent<IconProps> | undefined}
|
||||
header={result.status}
|
||||
isExpanded={result.isExpanded}
|
||||
onToggle={result.onToggle}
|
||||
collapsible={true}
|
||||
supportsCollapsible={result.supportsCollapsible}
|
||||
isLastStep={index === results.length - 1 && isLastStep}
|
||||
isFirstStep={index === 0 && isFirstStep}
|
||||
hideHeader={results.length === 1 && isSingleStep}
|
||||
collapsedIcon={
|
||||
isSearchTool
|
||||
? (result.icon as FunctionComponent<IconProps>)
|
||||
: undefined
|
||||
}
|
||||
noPaddingRight={isReasoning}
|
||||
>
|
||||
{result.content}
|
||||
</StepContainer>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[
|
||||
isResearchAgent,
|
||||
isSearchTool,
|
||||
isReasoning,
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSingleStep,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineRendererComponent
|
||||
packets={step.packets}
|
||||
chatState={chatState}
|
||||
onComplete={noopCallback}
|
||||
animate={!stopPacketSeen}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
defaultExpanded={isStreaming || isSingleStep}
|
||||
isLastStep={isLastStep}
|
||||
>
|
||||
{renderStep}
|
||||
</TimelineRendererComponent>
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// ExpandedTimelineContent Component
|
||||
// =============================================================================
|
||||
|
||||
export interface ExpandedTimelineContentProps {
|
||||
turnGroups: TurnGroup[];
|
||||
chatState: FullChatState;
|
||||
stopPacketSeen: boolean;
|
||||
stopReason?: StopReason;
|
||||
isSingleStep: boolean;
|
||||
userStopped: boolean;
|
||||
showDoneStep: boolean;
|
||||
showStoppedStep: boolean;
|
||||
hasDoneIndicator: boolean;
|
||||
}
|
||||
|
||||
export const ExpandedTimelineContent = React.memo(
|
||||
function ExpandedTimelineContent({
|
||||
turnGroups,
|
||||
chatState,
|
||||
stopPacketSeen,
|
||||
stopReason,
|
||||
isSingleStep,
|
||||
userStopped,
|
||||
showDoneStep,
|
||||
showStoppedStep,
|
||||
hasDoneIndicator,
|
||||
}: ExpandedTimelineContentProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{turnGroups.map((turnGroup, turnIdx) =>
|
||||
turnGroup.isParallel ? (
|
||||
<ParallelTimelineTabs
|
||||
key={turnGroup.turnIndex}
|
||||
turnGroup={turnGroup}
|
||||
chatState={chatState}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
isLastTurnGroup={turnIdx === turnGroups.length - 1}
|
||||
/>
|
||||
) : (
|
||||
turnGroup.steps.map((step, stepIdx) => {
|
||||
const stepIsLast =
|
||||
turnIdx === turnGroups.length - 1 &&
|
||||
stepIdx === turnGroup.steps.length - 1 &&
|
||||
!hasDoneIndicator &&
|
||||
!userStopped;
|
||||
const stepIsFirst = turnIdx === 0 && stepIdx === 0;
|
||||
|
||||
return (
|
||||
<TimelineStep
|
||||
key={step.key}
|
||||
step={step}
|
||||
chatState={chatState}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
isLastStep={stepIsLast}
|
||||
isFirstStep={stepIsFirst}
|
||||
isSingleStep={isSingleStep}
|
||||
isStreaming={!stopPacketSeen && !userStopped}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Done indicator */}
|
||||
{showDoneStep && (
|
||||
<StepContainer
|
||||
stepIcon={SvgCheckCircle}
|
||||
header="Done"
|
||||
isLastStep={true}
|
||||
isFirstStep={false}
|
||||
>
|
||||
{null}
|
||||
</StepContainer>
|
||||
)}
|
||||
|
||||
{/* Stopped indicator */}
|
||||
{showStoppedStep && (
|
||||
<StepContainer
|
||||
stepIcon={SvgStopCircle}
|
||||
header="Stopped"
|
||||
isLastStep={true}
|
||||
isFirstStep={false}
|
||||
>
|
||||
{null}
|
||||
</StepContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default ExpandedTimelineContent;
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
FunctionComponent,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { StopReason } from "@/app/app/services/streamingModels";
|
||||
import { FullChatState } from "../interfaces";
|
||||
import { TurnGroup } from "./transformers";
|
||||
import {
|
||||
getToolName,
|
||||
getToolIcon,
|
||||
isToolComplete,
|
||||
} from "../toolDisplayHelpers";
|
||||
import {
|
||||
TimelineRendererComponent,
|
||||
TimelineRendererOutput,
|
||||
} from "./TimelineRendererComponent";
|
||||
import Tabs from "@/refresh-components/Tabs";
|
||||
import { SvgBranch, SvgFold, SvgExpand } from "@opal/icons";
|
||||
import { StepContainer } from "./StepContainer";
|
||||
import { isResearchAgentPackets } from "@/app/app/message/messageComponents/timeline/packetHelpers";
|
||||
import { IconProps } from "@/components/icons/icons";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
|
||||
export interface ParallelTimelineTabsProps {
|
||||
/** Turn group containing parallel steps */
|
||||
turnGroup: TurnGroup;
|
||||
/** Chat state for rendering content */
|
||||
chatState: FullChatState;
|
||||
/** Whether the stop packet has been seen */
|
||||
stopPacketSeen: boolean;
|
||||
/** Reason for stopping (if stopped) */
|
||||
stopReason?: StopReason;
|
||||
/** Whether this is the last turn group (affects connector line) */
|
||||
isLastTurnGroup: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ParallelTimelineTabs({
|
||||
turnGroup,
|
||||
chatState,
|
||||
stopPacketSeen,
|
||||
stopReason,
|
||||
isLastTurnGroup,
|
||||
className,
|
||||
}: ParallelTimelineTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(turnGroup.steps[0]?.key ?? "");
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
|
||||
|
||||
// Find the active step based on selected tab
|
||||
const activeStep = useMemo(
|
||||
() => turnGroup.steps.find((step) => step.key === activeTab),
|
||||
[turnGroup.steps, activeTab]
|
||||
);
|
||||
|
||||
// Memoized loading states for each step
|
||||
const loadingStates = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
turnGroup.steps.map((step) => [
|
||||
step.key,
|
||||
!stopPacketSeen &&
|
||||
step.packets.length > 0 &&
|
||||
!isToolComplete(step.packets),
|
||||
])
|
||||
),
|
||||
[turnGroup.steps, stopPacketSeen]
|
||||
);
|
||||
|
||||
// Check if any step is a research agent (only research agents get collapse button)
|
||||
const hasResearchAgent = useMemo(
|
||||
() => turnGroup.steps.some((step) => isResearchAgentPackets(step.packets)),
|
||||
[turnGroup.steps]
|
||||
);
|
||||
//will be removed on cleanup
|
||||
// Stable callbacks to avoid creating new functions on every render
|
||||
const noopComplete = useCallback(() => {}, []);
|
||||
|
||||
const renderTabContent = useCallback(
|
||||
(results: TimelineRendererOutput) => {
|
||||
if (isResearchAgentPackets(activeStep?.packets ?? [])) {
|
||||
return (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<React.Fragment key={index}>{result.content}</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<StepContainer
|
||||
key={index}
|
||||
stepIcon={result.icon as FunctionComponent<IconProps> | undefined}
|
||||
header={result.status}
|
||||
isExpanded={result.isExpanded}
|
||||
onToggle={result.onToggle}
|
||||
collapsible={true}
|
||||
isLastStep={index === results.length - 1 && isLastTurnGroup}
|
||||
isFirstStep={false}
|
||||
isHover={result.isHover}
|
||||
>
|
||||
{result.content}
|
||||
</StepContainer>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[activeStep?.packets, isLastTurnGroup]
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="flex flex-col w-full">
|
||||
<div
|
||||
className="flex w-full"
|
||||
onMouseEnter={() => setIsHover(true)}
|
||||
onMouseLeave={() => setIsHover(false)}
|
||||
>
|
||||
{/* Left column: Icon + connector line */}
|
||||
<div className="flex flex-col items-center w-9 pt-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-5 flex items-center justify-center text-text-02",
|
||||
isHover &&
|
||||
"text-text-inverted-05 bg-background-neutral-inverted-00 rounded-full"
|
||||
)}
|
||||
>
|
||||
<SvgBranch className="w-3 h-3" />
|
||||
</div>
|
||||
{/* Connector line */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-px flex-1 bg-border-01",
|
||||
isHover && "bg-border-04"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column: Tabs + collapse button */}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full pl-1 bg-background-tint-00",
|
||||
isHover && "bg-background-tint-02"
|
||||
)}
|
||||
>
|
||||
<Tabs.List
|
||||
variant="pill"
|
||||
enableScrollArrows
|
||||
className={cn(
|
||||
isHover && "bg-background-tint-02",
|
||||
"transition-colors duration-200"
|
||||
)}
|
||||
rightContent={
|
||||
<IconButton
|
||||
tertiary
|
||||
onClick={handleToggle}
|
||||
icon={isExpanded ? SvgFold : SvgExpand}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{turnGroup.steps.map((step) => (
|
||||
<Tabs.Trigger
|
||||
key={step.key}
|
||||
value={step.key}
|
||||
variant="pill"
|
||||
isLoading={loadingStates.get(step.key)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{getToolIcon(step.packets)}
|
||||
{getToolName(step.packets)}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<TimelineRendererComponent
|
||||
key={`${activeTab}-${isExpanded}`}
|
||||
packets={
|
||||
!isExpanded && stopPacketSeen ? [] : activeStep?.packets ?? []
|
||||
}
|
||||
chatState={chatState}
|
||||
onComplete={noopComplete}
|
||||
animate={!stopPacketSeen}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
defaultExpanded={isExpanded}
|
||||
isLastStep={isLastTurnGroup}
|
||||
isHover={isHover}
|
||||
>
|
||||
{renderTabContent}
|
||||
</TimelineRendererComponent>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export default ParallelTimelineTabs;
|
||||
@@ -21,8 +21,8 @@ export interface StepContainerProps {
|
||||
onToggle?: () => void;
|
||||
/** Whether collapse control is shown */
|
||||
collapsible?: boolean;
|
||||
/** Collapse button shown only when renderer supports compact mode */
|
||||
supportsCompact?: boolean;
|
||||
/** Collapse button shown only when renderer supports collapsible mode */
|
||||
supportsCollapsible?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
/** Last step (no bottom connector) */
|
||||
@@ -31,6 +31,12 @@ export interface StepContainerProps {
|
||||
isFirstStep?: boolean;
|
||||
/** Hide header (single-step timelines) */
|
||||
hideHeader?: boolean;
|
||||
/** Hover state from parent */
|
||||
isHover?: boolean;
|
||||
/** Custom icon to show when collapsed (defaults to SvgExpand) */
|
||||
collapsedIcon?: FunctionComponent<IconProps>;
|
||||
/** Remove right padding (for reasoning content) */
|
||||
noPaddingRight?: boolean;
|
||||
}
|
||||
|
||||
/** Visual wrapper for timeline steps - icon, connector line, header, and content */
|
||||
@@ -42,50 +48,70 @@ export function StepContainer({
|
||||
isExpanded = true,
|
||||
onToggle,
|
||||
collapsible = true,
|
||||
supportsCompact = false,
|
||||
supportsCollapsible = false,
|
||||
isLastStep = false,
|
||||
isFirstStep = false,
|
||||
className,
|
||||
hideHeader = false,
|
||||
isHover = false,
|
||||
collapsedIcon: CollapsedIconComponent,
|
||||
noPaddingRight = false,
|
||||
}: StepContainerProps) {
|
||||
const showCollapseControls = collapsible && supportsCompact && onToggle;
|
||||
const showCollapseControls = collapsible && supportsCollapsible && onToggle;
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-full", className)}>
|
||||
<div
|
||||
className={cn("flex flex-col items-center w-9", isFirstStep && "pt-2")}
|
||||
className={cn(
|
||||
"flex flex-col items-center w-9",
|
||||
isFirstStep && "pt-0.5",
|
||||
!isFirstStep && "pt-1.5"
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
{!hideHeader && StepIconComponent && (
|
||||
<div className="py-1">
|
||||
<StepIconComponent className="size-4 stroke-text-02" />
|
||||
<div className="flex py-1 h-8 items-center justify-center">
|
||||
<StepIconComponent
|
||||
className={cn(
|
||||
"size-3 stroke-text-02",
|
||||
isHover && "stroke-text-04"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connector line */}
|
||||
{!isLastStep && <div className="w-px flex-1 bg-border-01" />}
|
||||
{!isLastStep && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-px h-full bg-border-01",
|
||||
isHover && "bg-border-04"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-full bg-background-tint-00",
|
||||
isLastStep && "rounded-b-12"
|
||||
"w-full bg-background-tint-00 transition-colors duration-200",
|
||||
isLastStep && "rounded-b-12",
|
||||
isHover && "bg-background-tint-02"
|
||||
)}
|
||||
>
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
{header && (
|
||||
<Text as="p" mainUiMuted text03>
|
||||
{header}
|
||||
</Text>
|
||||
)}
|
||||
{!hideHeader && header && (
|
||||
<div className="flex items-center justify-between pl-2 pr-1 h-8">
|
||||
<Text as="p" mainUiMuted text04>
|
||||
{header}
|
||||
</Text>
|
||||
|
||||
{showCollapseControls &&
|
||||
(buttonTitle ? (
|
||||
<Button
|
||||
tertiary
|
||||
onClick={onToggle}
|
||||
rightIcon={isExpanded ? SvgFold : SvgExpand}
|
||||
rightIcon={
|
||||
isExpanded ? SvgFold : CollapsedIconComponent || SvgExpand
|
||||
}
|
||||
>
|
||||
{buttonTitle}
|
||||
</Button>
|
||||
@@ -93,13 +119,17 @@ export function StepContainer({
|
||||
<IconButton
|
||||
tertiary
|
||||
onClick={onToggle}
|
||||
icon={isExpanded ? SvgFold : SvgExpand}
|
||||
icon={
|
||||
isExpanded ? SvgFold : CollapsedIconComponent || SvgExpand
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-2 pb-2">{children}</div>
|
||||
<div className={cn("px-2 pb-2", !noPaddingRight && "pr-8")}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, JSX } from "react";
|
||||
import { Packet, StopReason } from "@/app/chat/services/streamingModels";
|
||||
import { FullChatState, RenderType, RendererResult } from "../interfaces";
|
||||
import React, { useState, useCallback, JSX } from "react";
|
||||
import { Packet, StopReason } from "@/app/app/services/streamingModels";
|
||||
import {
|
||||
FullChatState,
|
||||
RenderType,
|
||||
RendererResult,
|
||||
RendererOutput,
|
||||
} from "../interfaces";
|
||||
import { findRenderer } from "../renderMessageComponent";
|
||||
|
||||
/** Extended result that includes collapse state */
|
||||
@@ -15,8 +20,13 @@ export interface TimelineRendererResult extends RendererResult {
|
||||
renderType: RenderType;
|
||||
/** Whether this is the last step (passed through from props) */
|
||||
isLastStep: boolean;
|
||||
/** Hover state from parent */
|
||||
isHover: boolean;
|
||||
}
|
||||
|
||||
// All renderers return an array of results
|
||||
export type TimelineRendererOutput = TimelineRendererResult[];
|
||||
|
||||
export interface TimelineRendererComponentProps {
|
||||
/** Packets to render */
|
||||
packets: Packet[];
|
||||
@@ -34,8 +44,12 @@ export interface TimelineRendererComponentProps {
|
||||
defaultExpanded?: boolean;
|
||||
/** Whether this is the last step in the timeline (for connector line decisions) */
|
||||
isLastStep?: boolean;
|
||||
/** Children render function - receives extended result with collapse state */
|
||||
children: (result: TimelineRendererResult) => JSX.Element;
|
||||
/** Hover state from parent component */
|
||||
isHover?: boolean;
|
||||
/** Override render type (if not set, derives from defaultExpanded) */
|
||||
renderTypeOverride?: RenderType;
|
||||
/** Children render function - receives extended result with collapse state (single or array) */
|
||||
children: (result: TimelineRendererOutput) => JSX.Element;
|
||||
}
|
||||
|
||||
// Custom comparison function to prevent unnecessary re-renders
|
||||
@@ -50,7 +64,9 @@ function arePropsEqual(
|
||||
prev.stopReason === next.stopReason &&
|
||||
prev.animate === next.animate &&
|
||||
prev.isLastStep === next.isLastStep &&
|
||||
prev.defaultExpanded === next.defaultExpanded
|
||||
prev.isHover === next.isHover &&
|
||||
prev.defaultExpanded === next.defaultExpanded &&
|
||||
prev.renderTypeOverride === next.renderTypeOverride
|
||||
// Skipping chatState (memoized upstream)
|
||||
);
|
||||
}
|
||||
@@ -65,26 +81,42 @@ export const TimelineRendererComponent = React.memo(
|
||||
stopReason,
|
||||
defaultExpanded = true,
|
||||
isLastStep,
|
||||
isHover = false,
|
||||
renderTypeOverride,
|
||||
children,
|
||||
}: TimelineRendererComponentProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const handleToggle = () => setIsExpanded((prev) => !prev);
|
||||
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
|
||||
const RendererFn = findRenderer({ packets });
|
||||
const renderType = isExpanded ? RenderType.FULL : RenderType.COMPACT;
|
||||
const renderType =
|
||||
renderTypeOverride ?? (isExpanded ? RenderType.FULL : RenderType.COMPACT);
|
||||
|
||||
if (!RendererFn) {
|
||||
return children({
|
||||
icon: null,
|
||||
status: null,
|
||||
content: <></>,
|
||||
supportsCompact: false,
|
||||
isExpanded,
|
||||
onToggle: handleToggle,
|
||||
renderType,
|
||||
isLastStep: isLastStep ?? true,
|
||||
});
|
||||
return children([
|
||||
{
|
||||
icon: null,
|
||||
status: null,
|
||||
content: <></>,
|
||||
supportsCollapsible: false,
|
||||
isExpanded,
|
||||
onToggle: handleToggle,
|
||||
renderType,
|
||||
isLastStep: isLastStep ?? true,
|
||||
isHover,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// Helper to add timeline context to a result
|
||||
const enhanceResult = (result: RendererResult): TimelineRendererResult => ({
|
||||
...result,
|
||||
isExpanded,
|
||||
onToggle: handleToggle,
|
||||
renderType,
|
||||
isLastStep: isLastStep ?? true,
|
||||
isHover,
|
||||
});
|
||||
|
||||
return (
|
||||
<RendererFn
|
||||
packets={packets as any}
|
||||
@@ -95,19 +127,10 @@ export const TimelineRendererComponent = React.memo(
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
isLastStep={isLastStep}
|
||||
isHover={isHover}
|
||||
>
|
||||
{({ icon, status, content, expandedText, supportsCompact }) =>
|
||||
children({
|
||||
icon,
|
||||
status,
|
||||
content,
|
||||
expandedText,
|
||||
supportsCompact,
|
||||
isExpanded,
|
||||
onToggle: handleToggle,
|
||||
renderType,
|
||||
isLastStep: isLastStep ?? true,
|
||||
})
|
||||
{(rendererOutput: RendererOutput) =>
|
||||
children(rendererOutput.map((result) => enhanceResult(result)))
|
||||
}
|
||||
</RendererFn>
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { SvgExpand } from "@opal/icons";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { formatDurationSeconds } from "@/lib/time";
|
||||
|
||||
export interface CollapsedHeaderProps {
|
||||
totalSteps: number;
|
||||
collapsible: boolean;
|
||||
onToggle: () => void;
|
||||
processingDurationSeconds?: number;
|
||||
generatedImageCount?: number;
|
||||
}
|
||||
|
||||
/** Header when completed + collapsed - duration text + step count */
|
||||
export const CollapsedHeader = React.memo(function CollapsedHeader({
|
||||
totalSteps,
|
||||
collapsible,
|
||||
onToggle,
|
||||
processingDurationSeconds = 0,
|
||||
generatedImageCount = 0,
|
||||
}: CollapsedHeaderProps) {
|
||||
const durationText = processingDurationSeconds
|
||||
? `Thought for ${formatDurationSeconds(processingDurationSeconds)}`
|
||||
: "Thought for some time";
|
||||
|
||||
const imageText =
|
||||
generatedImageCount > 0
|
||||
? `Generated ${generatedImageCount} ${
|
||||
generatedImageCount === 1 ? "image" : "images"
|
||||
}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
{!imageText && (
|
||||
<Text as="p" mainUiAction text03>
|
||||
{durationText}
|
||||
</Text>
|
||||
)}
|
||||
{imageText && (
|
||||
<Text as="p" mainUiAction text03>
|
||||
{imageText}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{collapsible && totalSteps > 0 && (
|
||||
<Button
|
||||
tertiary
|
||||
onClick={onToggle}
|
||||
rightIcon={SvgExpand}
|
||||
aria-label="Expand timeline"
|
||||
aria-expanded={false}
|
||||
>
|
||||
{totalSteps} {totalSteps === 1 ? "step" : "steps"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -2,21 +2,28 @@ import React from "react";
|
||||
import { SvgFold } from "@opal/icons";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { formatDurationSeconds } from "@/lib/time";
|
||||
|
||||
export interface ExpandedHeaderProps {
|
||||
collapsible: boolean;
|
||||
onToggle: () => void;
|
||||
processingDurationSeconds?: number;
|
||||
}
|
||||
|
||||
/** Header when completed + expanded */
|
||||
export const ExpandedHeader = React.memo(function ExpandedHeader({
|
||||
collapsible,
|
||||
onToggle,
|
||||
processingDurationSeconds,
|
||||
}: ExpandedHeaderProps) {
|
||||
const durationText = processingDurationSeconds
|
||||
? `Thought for ${formatDurationSeconds(processingDurationSeconds)}`
|
||||
: "Thought for some time";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text as="p" mainUiAction text03>
|
||||
Thought for some time
|
||||
{durationText}
|
||||
</Text>
|
||||
{collapsible && (
|
||||
<IconButton
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { SvgFold, SvgExpand } from "@opal/icons";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Tabs from "@/refresh-components/Tabs";
|
||||
import { TurnGroup } from "../transformers";
|
||||
import {
|
||||
getToolIcon,
|
||||
getToolName,
|
||||
isToolComplete,
|
||||
} from "../../toolDisplayHelpers";
|
||||
|
||||
export interface ParallelStreamingHeaderProps {
|
||||
steps: TurnGroup["steps"];
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
collapsible: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
/** Header during streaming with parallel tools - tabs only */
|
||||
export const ParallelStreamingHeader = React.memo(
|
||||
function ParallelStreamingHeader({
|
||||
steps,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
collapsible,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: ParallelStreamingHeaderProps) {
|
||||
// Memoized loading states for each step
|
||||
const loadingStates = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
steps.map((step) => [
|
||||
step.key,
|
||||
step.packets.length > 0 && !isToolComplete(step.packets),
|
||||
])
|
||||
),
|
||||
[steps]
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange}>
|
||||
<Tabs.List
|
||||
variant="pill"
|
||||
enableScrollArrows
|
||||
rightContent={
|
||||
collapsible ? (
|
||||
<IconButton
|
||||
tertiary
|
||||
onClick={onToggle}
|
||||
icon={isExpanded ? SvgFold : SvgExpand}
|
||||
aria-label={
|
||||
isExpanded ? "Collapse timeline" : "Expand timeline"
|
||||
}
|
||||
aria-expanded={isExpanded}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Tabs.Trigger
|
||||
key={step.key}
|
||||
value={step.key}
|
||||
variant="pill"
|
||||
isLoading={loadingStates.get(step.key)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{getToolIcon(step.packets)}
|
||||
{getToolName(step.packets)}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -20,9 +20,9 @@ export const StoppedHeader = React.memo(function StoppedHeader({
|
||||
return (
|
||||
<>
|
||||
<Text as="p" mainUiAction text03>
|
||||
Stopped Thinking
|
||||
Interrupted Thinking
|
||||
</Text>
|
||||
{collapsible && (
|
||||
{collapsible && totalSteps > 0 && (
|
||||
<Button
|
||||
tertiary
|
||||
onClick={onToggle}
|
||||
@@ -3,6 +3,8 @@ import { SvgFold, SvgExpand } from "@opal/icons";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { useStreamingDuration } from "../hooks/useStreamingDuration";
|
||||
import { formatDurationSeconds } from "@/lib/time";
|
||||
|
||||
export interface StreamingHeaderProps {
|
||||
headerText: string;
|
||||
@@ -10,6 +12,9 @@ export interface StreamingHeaderProps {
|
||||
buttonTitle?: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
streamingStartTime?: number;
|
||||
/** Tool processing duration from backend (freezes timer when available) */
|
||||
toolProcessingDuration?: number;
|
||||
}
|
||||
|
||||
/** Header during streaming - shimmer text with current activity */
|
||||
@@ -19,7 +24,18 @@ export const StreamingHeader = React.memo(function StreamingHeader({
|
||||
buttonTitle,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
streamingStartTime,
|
||||
toolProcessingDuration,
|
||||
}: StreamingHeaderProps) {
|
||||
// Use backend duration when available, otherwise continue live timer
|
||||
const elapsedSeconds = useStreamingDuration(
|
||||
toolProcessingDuration === undefined, // Stop updating when we have backend duration
|
||||
streamingStartTime,
|
||||
toolProcessingDuration
|
||||
);
|
||||
const showElapsedTime =
|
||||
isExpanded && streamingStartTime && elapsedSeconds > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
@@ -40,6 +56,16 @@ export const StreamingHeader = React.memo(function StreamingHeader({
|
||||
>
|
||||
{buttonTitle}
|
||||
</Button>
|
||||
) : showElapsedTime ? (
|
||||
<Button
|
||||
tertiary
|
||||
onClick={onToggle}
|
||||
rightIcon={SvgFold}
|
||||
aria-label="Collapse timeline"
|
||||
aria-expanded={true}
|
||||
>
|
||||
{formatDurationSeconds(elapsedSeconds)}
|
||||
</Button>
|
||||
) : (
|
||||
<IconButton
|
||||
tertiary
|
||||
@@ -8,17 +8,17 @@ import {
|
||||
FetchToolDocuments,
|
||||
TopLevelBranching,
|
||||
Stop,
|
||||
SearchToolStart,
|
||||
CustomToolStart,
|
||||
} from "@/app/chat/services/streamingModels";
|
||||
import { CitationMap } from "@/app/chat/interfaces";
|
||||
ImageGenerationToolDelta,
|
||||
MessageStart,
|
||||
} from "@/app/app/services/streamingModels";
|
||||
import { CitationMap } from "@/app/app/interfaces";
|
||||
import { OnyxDocument } from "@/lib/search/interfaces";
|
||||
import {
|
||||
isActualToolCallPacket,
|
||||
isToolPacket,
|
||||
isDisplayPacket,
|
||||
} from "@/app/chat/services/packetUtils";
|
||||
import { parseToolKey } from "@/app/chat/message/messageComponents/toolDisplayHelpers";
|
||||
} from "@/app/app/services/packetUtils";
|
||||
import { parseToolKey } from "@/app/app/message/messageComponents/toolDisplayHelpers";
|
||||
|
||||
// Re-export parseToolKey for consumers that import from this module
|
||||
export { parseToolKey };
|
||||
@@ -49,18 +49,21 @@ export interface ProcessorState {
|
||||
toolGroupKeys: Set<string>;
|
||||
displayGroupKeys: Set<string>;
|
||||
|
||||
// Unique tool names tracking (populated during packet processing)
|
||||
uniqueToolNames: Set<string>;
|
||||
// Image generation status
|
||||
isGeneratingImage: boolean;
|
||||
generatedImageCount: number;
|
||||
|
||||
// Streaming status
|
||||
finalAnswerComing: boolean;
|
||||
stopPacketSeen: boolean;
|
||||
stopReason: StopReason | undefined;
|
||||
|
||||
// Tool processing duration from backend (captured when MESSAGE_START arrives)
|
||||
toolProcessingDuration: number | undefined;
|
||||
|
||||
// Result arrays (built at end of processPackets)
|
||||
toolGroups: GroupedPacket[];
|
||||
potentialDisplayGroups: GroupedPacket[];
|
||||
uniqueToolNamesArray: string[];
|
||||
}
|
||||
|
||||
export interface GroupedPacket {
|
||||
@@ -87,13 +90,14 @@ export function createInitialState(nodeId: number): ProcessorState {
|
||||
expectedBranches: new Map(),
|
||||
toolGroupKeys: new Set(),
|
||||
displayGroupKeys: new Set(),
|
||||
uniqueToolNames: new Set(),
|
||||
isGeneratingImage: false,
|
||||
generatedImageCount: 0,
|
||||
finalAnswerComing: false,
|
||||
stopPacketSeen: false,
|
||||
stopReason: undefined,
|
||||
toolProcessingDuration: undefined,
|
||||
toolGroups: [],
|
||||
potentialDisplayGroups: [],
|
||||
uniqueToolNamesArray: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,37 +151,6 @@ function hasContentPackets(packets: Packet[]): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool name from a packet for unique tool tracking.
|
||||
* Returns null for non-tool packets.
|
||||
*/
|
||||
function getToolNameFromPacket(packet: Packet): string | null {
|
||||
switch (packet.obj.type) {
|
||||
case PacketType.SEARCH_TOOL_START: {
|
||||
const searchPacket = packet.obj as SearchToolStart;
|
||||
return searchPacket.is_internet_search ? "Web Search" : "Internal Search";
|
||||
}
|
||||
case PacketType.PYTHON_TOOL_START:
|
||||
return "Code Interpreter";
|
||||
case PacketType.FETCH_TOOL_START:
|
||||
return "Open URLs";
|
||||
case PacketType.CUSTOM_TOOL_START: {
|
||||
const customPacket = packet.obj as CustomToolStart;
|
||||
return customPacket.tool_name || "Custom Tool";
|
||||
}
|
||||
case PacketType.IMAGE_GENERATION_TOOL_START:
|
||||
return "Generate Image";
|
||||
case PacketType.DEEP_RESEARCH_PLAN_START:
|
||||
return "Generate plan";
|
||||
case PacketType.RESEARCH_AGENT_START:
|
||||
return "Research agent";
|
||||
case PacketType.REASONING_START:
|
||||
return "Thinking";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Packet types that indicate final answer content is coming
|
||||
*/
|
||||
@@ -272,6 +245,15 @@ function handleStreamingStatusPacket(
|
||||
if (FINAL_ANSWER_PACKET_TYPES_SET.has(packet.obj.type as PacketType)) {
|
||||
state.finalAnswerComing = true;
|
||||
}
|
||||
|
||||
// Capture tool processing duration from MESSAGE_START packet
|
||||
if (packet.obj.type === PacketType.MESSAGE_START) {
|
||||
const messageStart = packet.obj as MessageStart;
|
||||
if (messageStart.tool_processing_duration_seconds !== undefined) {
|
||||
state.toolProcessingDuration =
|
||||
messageStart.tool_processing_duration_seconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleStopPacket(state: ProcessorState, packet: Packet): void {
|
||||
@@ -363,15 +345,21 @@ function processPacket(state: ProcessorState, packet: Packet): void {
|
||||
if (isFirstPacket) {
|
||||
if (isToolPacket(packet, false)) {
|
||||
state.toolGroupKeys.add(groupKey);
|
||||
// Track unique tool name
|
||||
const toolName = getToolNameFromPacket(packet);
|
||||
if (toolName) {
|
||||
state.uniqueToolNames.add(toolName);
|
||||
}
|
||||
}
|
||||
if (isDisplayPacket(packet)) {
|
||||
state.displayGroupKeys.add(groupKey);
|
||||
}
|
||||
|
||||
// Track image generation for header display
|
||||
if (packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_START) {
|
||||
state.isGeneratingImage = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Count generated images from DELTA packets
|
||||
if (packet.obj.type === PacketType.IMAGE_GENERATION_TOOL_DELTA) {
|
||||
const delta = packet.obj as ImageGenerationToolDelta;
|
||||
state.generatedImageCount += delta.images?.length ?? 0;
|
||||
}
|
||||
|
||||
// Handle specific packet types
|
||||
@@ -391,6 +379,9 @@ export function processPackets(
|
||||
state = createInitialState(state.nodeId);
|
||||
}
|
||||
|
||||
// Track if we processed any new packets
|
||||
const prevProcessedIndex = state.lastProcessedIndex;
|
||||
|
||||
// Process only new packets
|
||||
for (let i = state.lastProcessedIndex; i < rawPackets.length; i++) {
|
||||
const packet = rawPackets[i];
|
||||
@@ -401,13 +392,16 @@ export function processPackets(
|
||||
|
||||
state.lastProcessedIndex = rawPackets.length;
|
||||
|
||||
// Build result arrays after processing
|
||||
state.toolGroups = buildGroupsFromKeys(state, state.toolGroupKeys);
|
||||
state.potentialDisplayGroups = buildGroupsFromKeys(
|
||||
state,
|
||||
state.displayGroupKeys
|
||||
);
|
||||
state.uniqueToolNamesArray = Array.from(state.uniqueToolNames);
|
||||
// Only rebuild result arrays if we processed new packets
|
||||
// This prevents creating new references when nothing changed
|
||||
if (prevProcessedIndex !== rawPackets.length) {
|
||||
// Build result arrays after processing new packets
|
||||
state.toolGroups = buildGroupsFromKeys(state, state.toolGroupKeys);
|
||||
state.potentialDisplayGroups = buildGroupsFromKeys(
|
||||
state,
|
||||
state.displayGroupKeys
|
||||
);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -415,6 +409,43 @@ export function processPackets(
|
||||
/**
|
||||
* Build GroupedPacket array from a set of group keys.
|
||||
* Filters to only include groups with meaningful content and sorts by turn/tab index.
|
||||
*
|
||||
* @example
|
||||
* // Input: state.groupedPacketsMap + keys Set
|
||||
* // ┌─────────────────────────────────────────────────────┐
|
||||
* // │ groupedPacketsMap = { │
|
||||
* // │ "0-0" → [packet1, packet2] │
|
||||
* // │ "0-1" → [packet3] │
|
||||
* // │ "1-0" → [packet4, packet5] │
|
||||
* // │ "2-0" → [empty_packet] ← no content packets │
|
||||
* // │ } │
|
||||
* // │ keys = Set{"0-0", "0-1", "1-0", "2-0"} │
|
||||
* // └─────────────────────────────────────────────────────┘
|
||||
* //
|
||||
* // Step 1: Map keys → GroupedPacket (parse key, lookup packets)
|
||||
* // ┌─────────────────────────────────────────────────────┐
|
||||
* // │ "0-0" → { turn_index:0, tab_index:0, packets:[...] }│
|
||||
* // │ "0-1" → { turn_index:0, tab_index:1, packets:[...] }│
|
||||
* // │ "1-0" → { turn_index:1, tab_index:0, packets:[...] }│
|
||||
* // │ "2-0" → { turn_index:2, tab_index:0, packets:[...] }│
|
||||
* // └─────────────────────────────────────────────────────┘
|
||||
* //
|
||||
* // Step 2: Filter (hasContentPackets check)
|
||||
* // ┌─────────────────────────────────────────────────────┐
|
||||
* // │ ✓ "0-0" has MESSAGE_START → keep │
|
||||
* // │ ✓ "0-1" has SEARCH_TOOL_START → keep │
|
||||
* // │ ✓ "1-0" has PYTHON_TOOL_START → keep │
|
||||
* // │ ✗ "2-0" no content packets → filtered out │
|
||||
* // └─────────────────────────────────────────────────────┘
|
||||
* //
|
||||
* // Step 3: Sort by turn_index, then tab_index
|
||||
* // ┌─────────────────────────────────────────────────────┐
|
||||
* // │ Output: GroupedPacket[] │
|
||||
* // ├─────────────────────────────────────────────────────┤
|
||||
* // │ [0] turn_index=0, tab_index=0, packets=[...] │
|
||||
* // │ [1] turn_index=0, tab_index=1, packets=[...] │
|
||||
* // │ [2] turn_index=1, tab_index=0, packets=[...] │
|
||||
* // └─────────────────────────────────────────────────────┘
|
||||
*/
|
||||
function buildGroupsFromKeys(
|
||||
state: ProcessorState,
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { PacketType } from "@/app/app/services/streamingModels";
|
||||
import { GroupedPacket } from "./packetProcessor";
|
||||
import { TurnGroup, TransformedStep } from "../transformers";
|
||||
|
||||
// Delay between different packet types (ms)
|
||||
const PACING_DELAY_MS = 200;
|
||||
|
||||
/**
|
||||
* Tool START packet types used for categorizing steps
|
||||
* These determine the "type" of a step for pacing purposes
|
||||
*/
|
||||
const TOOL_START_PACKET_TYPES = new Set<PacketType>([
|
||||
PacketType.SEARCH_TOOL_START,
|
||||
PacketType.FETCH_TOOL_START,
|
||||
PacketType.PYTHON_TOOL_START,
|
||||
PacketType.CUSTOM_TOOL_START,
|
||||
PacketType.REASONING_START,
|
||||
PacketType.IMAGE_GENERATION_TOOL_START,
|
||||
PacketType.DEEP_RESEARCH_PLAN_START,
|
||||
PacketType.RESEARCH_AGENT_START,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get the primary packet type from a step's packets (first START packet)
|
||||
* Used to determine if a type transition occurred
|
||||
*/
|
||||
function getStepPacketType(step: TransformedStep): PacketType | null {
|
||||
for (const packet of step.packets) {
|
||||
if (TOOL_START_PACKET_TYPES.has(packet.obj.type as PacketType)) {
|
||||
return packet.obj.type as PacketType;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pacing state stored in ref (not triggering re-renders)
|
||||
*/
|
||||
interface PacingState {
|
||||
// Tracking revealed content
|
||||
revealedStepKeys: Set<string>;
|
||||
lastRevealedPacketType: PacketType | null;
|
||||
|
||||
// Queued content
|
||||
pendingSteps: TransformedStep[];
|
||||
|
||||
// Timer
|
||||
pacingTimer: ReturnType<typeof setTimeout> | null;
|
||||
|
||||
// Flags
|
||||
toolPacingComplete: boolean;
|
||||
stopPacketSeen: boolean;
|
||||
|
||||
// Track nodeId for reset detection
|
||||
nodeId: string | null;
|
||||
}
|
||||
|
||||
function createInitialPacingState(): PacingState {
|
||||
return {
|
||||
revealedStepKeys: new Set(),
|
||||
lastRevealedPacketType: null,
|
||||
pendingSteps: [],
|
||||
pacingTimer: null,
|
||||
toolPacingComplete: false,
|
||||
stopPacketSeen: false,
|
||||
nodeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that adds pacing delays when packet types change during streaming.
|
||||
* Creates visual breathing room between different agent activities.
|
||||
*
|
||||
* Architecture:
|
||||
* - Pacing state in ref: no re-renders for internal tracking
|
||||
* - useState only for revealTrigger: forces re-render when content should update
|
||||
* - Timer-based delays: 200ms between different packet types
|
||||
*
|
||||
* @param toolTurnGroups - Turn groups from packet processor
|
||||
* @param displayGroups - Display content groups (MESSAGE_START/DELTA)
|
||||
* @param stopPacketSeen - Whether STOP packet has been received
|
||||
* @param nodeId - Message node ID for reset detection
|
||||
* @param finalAnswerComing - Whether message content is streaming
|
||||
*/
|
||||
export function usePacedTurnGroups(
|
||||
toolTurnGroups: TurnGroup[],
|
||||
displayGroups: GroupedPacket[],
|
||||
stopPacketSeen: boolean,
|
||||
nodeId: number,
|
||||
finalAnswerComing: boolean
|
||||
): {
|
||||
pacedTurnGroups: TurnGroup[];
|
||||
pacedDisplayGroups: GroupedPacket[];
|
||||
pacedFinalAnswerComing: boolean;
|
||||
} {
|
||||
// Ref-based pacing state (no re-renders)
|
||||
const stateRef = useRef<PacingState>(createInitialPacingState());
|
||||
|
||||
// Track previous finalAnswerComing to detect tool-after-message transitions
|
||||
const prevFinalAnswerComingRef = useRef(finalAnswerComing);
|
||||
|
||||
// Trigger re-render when content should update
|
||||
// Used in useMemo dependencies since state.revealedStepKeys is stored in a ref
|
||||
const [revealTrigger, setRevealTrigger] = useState(0);
|
||||
|
||||
// Stable nodeId string for comparison
|
||||
const nodeIdStr = String(nodeId);
|
||||
|
||||
// Reset on nodeId change
|
||||
if (stateRef.current.nodeId !== nodeIdStr) {
|
||||
if (stateRef.current.pacingTimer) {
|
||||
clearTimeout(stateRef.current.pacingTimer);
|
||||
}
|
||||
stateRef.current = createInitialPacingState();
|
||||
stateRef.current.nodeId = nodeIdStr;
|
||||
}
|
||||
|
||||
const state = stateRef.current;
|
||||
|
||||
// Bypass pacing for completed messages (old messages loaded from history)
|
||||
// If stopPacketSeen is true on first render, return everything immediately
|
||||
const shouldBypassPacing =
|
||||
stopPacketSeen &&
|
||||
state.revealedStepKeys.size === 0 &&
|
||||
toolTurnGroups.length > 0;
|
||||
|
||||
// Handle revealing the next pending step
|
||||
// Uses a while loop to reveal all consecutive steps of the same type in a single pass
|
||||
const revealNextPendingStep = useCallback(() => {
|
||||
const state = stateRef.current;
|
||||
|
||||
// Reveal all consecutive steps of the same type in a single pass
|
||||
while (state.pendingSteps.length > 0) {
|
||||
const stepToReveal = state.pendingSteps.shift()!;
|
||||
state.revealedStepKeys.add(stepToReveal.key);
|
||||
state.lastRevealedPacketType = getStepPacketType(stepToReveal);
|
||||
|
||||
// Check if next step has different type - if so, schedule delay and exit
|
||||
if (state.pendingSteps.length > 0) {
|
||||
const nextType = getStepPacketType(state.pendingSteps[0]!);
|
||||
if (nextType !== state.lastRevealedPacketType) {
|
||||
state.pacingTimer = setTimeout(
|
||||
revealNextPendingStep,
|
||||
PACING_DELAY_MS
|
||||
);
|
||||
setRevealTrigger((t) => t + 1);
|
||||
return;
|
||||
}
|
||||
// Same type - continue loop to reveal next immediately
|
||||
}
|
||||
}
|
||||
|
||||
// No more pending steps - pacing complete
|
||||
state.toolPacingComplete = true;
|
||||
state.pacingTimer = null;
|
||||
setRevealTrigger((t) => t + 1);
|
||||
}, []);
|
||||
|
||||
// Process incoming turn groups
|
||||
useEffect(() => {
|
||||
// Skip processing when bypassing pacing
|
||||
if (shouldBypassPacing) return;
|
||||
|
||||
const state = stateRef.current;
|
||||
|
||||
// Detect tool-after-message transition: message was showing, now tools are starting
|
||||
// Reset toolPacingComplete to hide display until new tools finish pacing
|
||||
if (prevFinalAnswerComingRef.current && !finalAnswerComing) {
|
||||
state.toolPacingComplete = false;
|
||||
}
|
||||
prevFinalAnswerComingRef.current = finalAnswerComing;
|
||||
|
||||
// Handle STOP packet - flush everything immediately
|
||||
if (stopPacketSeen && !state.stopPacketSeen) {
|
||||
state.stopPacketSeen = true;
|
||||
|
||||
// Clear any pending timer
|
||||
if (state.pacingTimer) {
|
||||
clearTimeout(state.pacingTimer);
|
||||
state.pacingTimer = null;
|
||||
}
|
||||
|
||||
// Reveal all pending steps immediately
|
||||
for (const step of state.pendingSteps) {
|
||||
state.revealedStepKeys.add(step.key);
|
||||
}
|
||||
state.pendingSteps = [];
|
||||
state.toolPacingComplete = true;
|
||||
|
||||
setRevealTrigger((t) => t + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all steps from turn groups
|
||||
const allSteps: TransformedStep[] = [];
|
||||
for (const turnGroup of toolTurnGroups) {
|
||||
for (const step of turnGroup.steps) {
|
||||
allSteps.push(step);
|
||||
}
|
||||
}
|
||||
|
||||
// Find new steps (not yet revealed or pending)
|
||||
const newSteps: TransformedStep[] = [];
|
||||
const pendingKeys = new Set(state.pendingSteps.map((s) => s.key));
|
||||
|
||||
for (const step of allSteps) {
|
||||
if (!state.revealedStepKeys.has(step.key) && !pendingKeys.has(step.key)) {
|
||||
newSteps.push(step);
|
||||
}
|
||||
}
|
||||
|
||||
if (newSteps.length === 0) {
|
||||
// If there are no tool steps at all, mark pacing complete immediately
|
||||
// This allows tool-less responses to render their displayGroups
|
||||
if (allSteps.length === 0 && !state.toolPacingComplete) {
|
||||
state.toolPacingComplete = true;
|
||||
setRevealTrigger((t) => t + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if all steps are revealed (no pending, no new)
|
||||
if (
|
||||
state.pendingSteps.length === 0 &&
|
||||
!state.pacingTimer &&
|
||||
allSteps.length > 0
|
||||
) {
|
||||
const allRevealed = allSteps.every((s) =>
|
||||
state.revealedStepKeys.has(s.key)
|
||||
);
|
||||
if (allRevealed && !state.toolPacingComplete) {
|
||||
state.toolPacingComplete = true;
|
||||
setRevealTrigger((t) => t + 1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Process new steps
|
||||
for (const step of newSteps) {
|
||||
const stepType = getStepPacketType(step);
|
||||
|
||||
// First step ever - reveal immediately
|
||||
if (
|
||||
state.revealedStepKeys.size === 0 &&
|
||||
state.pendingSteps.length === 0
|
||||
) {
|
||||
state.revealedStepKeys.add(step.key);
|
||||
state.lastRevealedPacketType = stepType;
|
||||
setRevealTrigger((t) => t + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Same type as last revealed (or pending) - handle based on current state
|
||||
const effectiveLastType =
|
||||
state.pendingSteps.length > 0
|
||||
? getStepPacketType(
|
||||
state.pendingSteps[state.pendingSteps.length - 1]!
|
||||
)
|
||||
: state.lastRevealedPacketType;
|
||||
|
||||
if (stepType === effectiveLastType) {
|
||||
// Same type
|
||||
if (state.pendingSteps.length === 0 && !state.pacingTimer) {
|
||||
// Nothing pending, no timer - reveal immediately
|
||||
state.revealedStepKeys.add(step.key);
|
||||
state.lastRevealedPacketType = stepType;
|
||||
setRevealTrigger((t) => t + 1);
|
||||
} else {
|
||||
// Add to pending queue (will be revealed when timer fires or queue processes)
|
||||
state.pendingSteps.push(step);
|
||||
}
|
||||
} else {
|
||||
// Different type - queue for paced reveal
|
||||
state.pendingSteps.push(step);
|
||||
|
||||
// Start timer if not already running
|
||||
if (!state.pacingTimer && state.pendingSteps.length === 1) {
|
||||
state.pacingTimer = setTimeout(
|
||||
revealNextPendingStep,
|
||||
PACING_DELAY_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark pacing incomplete while we have pending steps or timer
|
||||
if (state.pendingSteps.length > 0 || state.pacingTimer) {
|
||||
state.toolPacingComplete = false;
|
||||
}
|
||||
}, [
|
||||
toolTurnGroups,
|
||||
stopPacketSeen,
|
||||
finalAnswerComing,
|
||||
revealNextPendingStep,
|
||||
shouldBypassPacing,
|
||||
]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (stateRef.current.pacingTimer) {
|
||||
clearTimeout(stateRef.current.pacingTimer);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build paced turn groups from revealed step keys
|
||||
// Memoized to prevent unnecessary re-renders in downstream components
|
||||
// revealTrigger is included because state.revealedStepKeys is stored in a ref
|
||||
const pacedTurnGroups = useMemo(() => {
|
||||
// Bypass: return all turn groups immediately
|
||||
if (shouldBypassPacing) return toolTurnGroups;
|
||||
|
||||
const result: TurnGroup[] = [];
|
||||
for (const turnGroup of toolTurnGroups) {
|
||||
const revealedSteps = turnGroup.steps.filter((step) =>
|
||||
state.revealedStepKeys.has(step.key)
|
||||
);
|
||||
if (revealedSteps.length > 0) {
|
||||
result.push({
|
||||
turnIndex: turnGroup.turnIndex,
|
||||
steps: revealedSteps,
|
||||
isParallel: revealedSteps.length > 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [toolTurnGroups, revealTrigger, shouldBypassPacing]);
|
||||
|
||||
// Only return display groups when tool pacing is complete (or bypassing)
|
||||
const pacedDisplayGroups = useMemo(
|
||||
() => (shouldBypassPacing || state.toolPacingComplete ? displayGroups : []),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[state.toolPacingComplete, displayGroups, revealTrigger, shouldBypassPacing]
|
||||
);
|
||||
|
||||
// Paced signals for header state consistency
|
||||
// Only signal finalAnswerComing when tool pacing is complete (or bypassing)
|
||||
const pacedFinalAnswerComing = useMemo(
|
||||
() => (shouldBypassPacing || state.toolPacingComplete) && finalAnswerComing,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
state.toolPacingComplete,
|
||||
finalAnswerComing,
|
||||
revealTrigger,
|
||||
shouldBypassPacing,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
pacedTurnGroups,
|
||||
pacedDisplayGroups,
|
||||
pacedFinalAnswerComing,
|
||||
};
|
||||
}
|
||||
@@ -3,20 +3,20 @@ import {
|
||||
Packet,
|
||||
StreamingCitation,
|
||||
StopReason,
|
||||
} from "@/app/chat/services/streamingModels";
|
||||
import { CitationMap } from "@/app/chat/interfaces";
|
||||
} from "@/app/app/services/streamingModels";
|
||||
import { CitationMap } from "@/app/app/interfaces";
|
||||
import { OnyxDocument } from "@/lib/search/interfaces";
|
||||
import {
|
||||
ProcessorState,
|
||||
GroupedPacket,
|
||||
createInitialState,
|
||||
processPackets,
|
||||
} from "@/app/chat/message/messageComponents/timeline/hooks/packetProcessor";
|
||||
} from "@/app/app/message/messageComponents/timeline/hooks/packetProcessor";
|
||||
import {
|
||||
transformPacketGroups,
|
||||
groupStepsByTurn,
|
||||
TurnGroup,
|
||||
} from "@/app/chat/message/messageComponents/timeline/transformers";
|
||||
} from "@/app/app/message/messageComponents/timeline/transformers";
|
||||
|
||||
export interface UsePacketProcessorResult {
|
||||
// Data
|
||||
@@ -32,7 +32,12 @@ export interface UsePacketProcessorResult {
|
||||
stopReason: StopReason | undefined;
|
||||
hasSteps: boolean;
|
||||
expectedBranchesPerTurn: Map<number, number>;
|
||||
uniqueToolNames: string[];
|
||||
isGeneratingImage: boolean;
|
||||
generatedImageCount: number;
|
||||
// Whether final answer is coming (MESSAGE_START seen)
|
||||
finalAnswerComing: boolean;
|
||||
// Tool processing duration from backend (via MESSAGE_START packet)
|
||||
toolProcessingDuration: number | undefined;
|
||||
|
||||
// Completion: stopPacketSeen && renderComplete
|
||||
isComplete: boolean;
|
||||
@@ -141,7 +146,10 @@ export function usePacketProcessor(
|
||||
stopReason: state.stopReason,
|
||||
hasSteps: toolTurnGroups.length > 0,
|
||||
expectedBranchesPerTurn: state.expectedBranches,
|
||||
uniqueToolNames: state.uniqueToolNamesArray,
|
||||
isGeneratingImage: state.isGeneratingImage,
|
||||
generatedImageCount: state.generatedImageCount,
|
||||
finalAnswerComing: state.finalAnswerComing,
|
||||
toolProcessingDuration: state.toolProcessingDuration,
|
||||
|
||||
// Completion: stopPacketSeen && renderComplete
|
||||
isComplete: state.stopPacketSeen && renderComplete,
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Hook to track elapsed streaming duration with efficient updates.
|
||||
*
|
||||
* Uses requestAnimationFrame for accurate timing but only triggers re-renders
|
||||
* when the elapsed seconds value actually changes (once per second).
|
||||
*
|
||||
* @param isStreaming - Whether streaming is currently active
|
||||
* @param startTime - Timestamp when streaming started (from Date.now())
|
||||
* @param backendDuration - Duration from backend when available (freezes timer)
|
||||
* @returns Elapsed seconds since streaming started
|
||||
*/
|
||||
export function useStreamingDuration(
|
||||
isStreaming: boolean,
|
||||
startTime: number | undefined,
|
||||
backendDuration?: number
|
||||
): number {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const lastElapsedRef = useRef<number>(0);
|
||||
|
||||
// Determine if we should run the live timer
|
||||
// Stop the timer when backend duration is available
|
||||
const shouldRunTimer = isStreaming && backendDuration === undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRunTimer || !startTime) {
|
||||
// Don't reset when stopping - preserve last calculated value
|
||||
// Only reset when explicitly given no start time
|
||||
if (!startTime) {
|
||||
setElapsedSeconds(0);
|
||||
lastElapsedRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const updateElapsed = () => {
|
||||
const now = Date.now();
|
||||
const elapsed = Math.floor((now - startTime) / 1000);
|
||||
|
||||
// Only update state when seconds change to avoid unnecessary re-renders
|
||||
if (elapsed !== lastElapsedRef.current) {
|
||||
lastElapsedRef.current = elapsed;
|
||||
setElapsedSeconds(elapsed);
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(updateElapsed);
|
||||
};
|
||||
|
||||
// Start the animation loop
|
||||
rafRef.current = requestAnimationFrame(updateElapsed);
|
||||
|
||||
return () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shouldRunTimer, startTime]);
|
||||
|
||||
// Return backend duration if provided, otherwise return live elapsed time
|
||||
return backendDuration !== undefined ? backendDuration : elapsedSeconds;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { TurnGroup } from "../transformers";
|
||||
|
||||
export interface TimelineExpansionState {
|
||||
@@ -10,25 +10,29 @@ export interface TimelineExpansionState {
|
||||
|
||||
/**
|
||||
* Manages expansion state for the timeline.
|
||||
* Auto-collapses when streaming completes and syncs parallel tab selection.
|
||||
* Auto-collapses when streaming completes or message content starts, and syncs parallel tab selection.
|
||||
*/
|
||||
export function useTimelineExpansion(
|
||||
stopPacketSeen: boolean,
|
||||
lastTurnGroup: TurnGroup | undefined
|
||||
lastTurnGroup: TurnGroup | undefined,
|
||||
hasDisplayContent: boolean = false
|
||||
): TimelineExpansionState {
|
||||
const [isExpanded, setIsExpanded] = useState(!stopPacketSeen);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [parallelActiveTab, setParallelActiveTab] = useState<string>("");
|
||||
const userHasToggled = useRef(false);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
userHasToggled.current = true;
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Auto-collapse when streaming completes
|
||||
// Auto-collapse when streaming completes or message content starts
|
||||
// BUT respect user intent - if they've manually toggled, don't auto-collapse
|
||||
useEffect(() => {
|
||||
if (stopPacketSeen) {
|
||||
if ((stopPacketSeen || hasDisplayContent) && !userHasToggled.current) {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
}, [stopPacketSeen]);
|
||||
}, [stopPacketSeen, hasDisplayContent]);
|
||||
|
||||
// Sync active tab when parallel turn group changes
|
||||
useEffect(() => {
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
SearchToolPacket,
|
||||
StopReason,
|
||||
CustomToolStart,
|
||||
} from "@/app/chat/services/streamingModels";
|
||||
import { constructCurrentSearchState } from "@/app/chat/message/messageComponents/timeline/renderers/search/searchStateUtils";
|
||||
} from "@/app/app/services/streamingModels";
|
||||
import { constructCurrentSearchState } from "@/app/app/message/messageComponents/timeline/renderers/search/searchStateUtils";
|
||||
|
||||
export interface TimelineHeaderResult {
|
||||
headerText: string;
|
||||
@@ -20,12 +20,18 @@ export interface TimelineHeaderResult {
|
||||
*/
|
||||
export function useTimelineHeader(
|
||||
turnGroups: TurnGroup[],
|
||||
stopReason?: StopReason
|
||||
stopReason?: StopReason,
|
||||
isGeneratingImage?: boolean
|
||||
): TimelineHeaderResult {
|
||||
return useMemo(() => {
|
||||
const hasPackets = turnGroups.length > 0;
|
||||
const userStopped = stopReason === StopReason.USER_CANCELLED;
|
||||
|
||||
// If generating image with no tool packets, show image generation header
|
||||
if (isGeneratingImage && !hasPackets) {
|
||||
return { headerText: "Generating image...", hasPackets, userStopped };
|
||||
}
|
||||
|
||||
if (!hasPackets) {
|
||||
return { headerText: "Thinking...", hasPackets, userStopped };
|
||||
}
|
||||
@@ -53,14 +59,19 @@ export function useTimelineHeader(
|
||||
const searchState = constructCurrentSearchState(
|
||||
currentStep.packets as SearchToolPacket[]
|
||||
);
|
||||
const headerText = searchState.isInternetSearch
|
||||
? "Searching web"
|
||||
: "Searching internal documents";
|
||||
let headerText: string;
|
||||
if (searchState.hasResults && !searchState.isInternetSearch) {
|
||||
headerText = "Reading";
|
||||
} else {
|
||||
headerText = searchState.isInternetSearch
|
||||
? "Searching the web"
|
||||
: "Searching internal documents";
|
||||
}
|
||||
return { headerText, hasPackets, userStopped };
|
||||
}
|
||||
|
||||
if (packetType === PacketType.FETCH_TOOL_START) {
|
||||
return { headerText: "Opening URLs", hasPackets, userStopped };
|
||||
return { headerText: "Reading", hasPackets, userStopped };
|
||||
}
|
||||
|
||||
if (packetType === PacketType.PYTHON_TOOL_START) {
|
||||
@@ -93,5 +104,5 @@ export function useTimelineHeader(
|
||||
}
|
||||
|
||||
return { headerText: "Thinking...", hasPackets, userStopped };
|
||||
}, [turnGroups, stopReason]);
|
||||
}, [turnGroups, stopReason, isGeneratingImage]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user