mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
show chat progress and other UX improvements (#6576)
This commit is contained in:
+15
-2
@@ -1,5 +1,12 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Engine-specific names to avoid overriding the root Taskfile's FIND_FREE_PORT_*
|
||||||
|
# vars (Task merges included-file vars into the global scope).
|
||||||
|
# Paths are relative to the engine/ include dir.
|
||||||
|
ENGINE_FIND_FREE_PORT_SH: "bash ../scripts/find-free-port.sh"
|
||||||
|
ENGINE_FIND_FREE_PORT_PS: "powershell -NoProfile -File ../scripts/find-free-port.ps1"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
install:
|
install:
|
||||||
desc: "Install engine dependencies"
|
desc: "Install engine dependencies"
|
||||||
@@ -29,7 +36,10 @@ tasks:
|
|||||||
ignore_error: true
|
ignore_error: true
|
||||||
dir: src
|
dir: src
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.PORT | default "5001"}}'
|
# When PORT is provided (e.g. from dev:all), use it directly.
|
||||||
|
# When running standalone, probe for a free port starting at 5001.
|
||||||
|
PORT:
|
||||||
|
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
|
||||||
env:
|
env:
|
||||||
PYTHONUNBUFFERED: "1"
|
PYTHONUNBUFFERED: "1"
|
||||||
cmds:
|
cmds:
|
||||||
@@ -41,7 +51,10 @@ tasks:
|
|||||||
ignore_error: true
|
ignore_error: true
|
||||||
dir: src
|
dir: src
|
||||||
vars:
|
vars:
|
||||||
PORT: '{{.PORT | default "5001"}}'
|
# When PORT is provided (e.g. from dev:all), use it directly.
|
||||||
|
# When running standalone, probe for a free port starting at 5001.
|
||||||
|
PORT:
|
||||||
|
sh: '{{if .PORT}}echo {{.PORT}}{{else if eq OS "windows"}}{{.ENGINE_FIND_FREE_PORT_PS}} 5001{{else}}{{.ENGINE_FIND_FREE_PORT_SH}} 5001{{end}}'
|
||||||
env:
|
env:
|
||||||
PYTHONUNBUFFERED: "1"
|
PYTHONUNBUFFERED: "1"
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
@@ -2700,6 +2700,13 @@ whole_doc_compression_round = "Consolidating notes..."
|
|||||||
whole_doc_read_done = "Finished reading the document..."
|
whole_doc_read_done = "Finished reading the document..."
|
||||||
whole_doc_read_started = "Reading the document..."
|
whole_doc_read_started = "Reading the document..."
|
||||||
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
|
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
|
||||||
|
ranForSeconds = "Ran for {{count}} seconds"
|
||||||
|
ranForSeconds_one = "Ran for 1 second"
|
||||||
|
ranForSeconds_other = "Ran for {{count}} seconds"
|
||||||
|
ranForMinutes = "Ran for {{count}} minutes"
|
||||||
|
ranForMinutes_one = "Ran for 1 minute"
|
||||||
|
ranForMinutes_other = "Ran for {{count}} minutes"
|
||||||
|
ranForMinutesSeconds = "Ran for {{minutes}} min, {{seconds}} sec"
|
||||||
|
|
||||||
[chat.quickActions]
|
[chat.quickActions]
|
||||||
browseYourFiles = "Browse your files"
|
browseYourFiles = "Browse your files"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function useChat() {
|
|||||||
isOpen: false,
|
isOpen: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
progress: null,
|
progress: null,
|
||||||
|
progressLog: [] as never[],
|
||||||
toggleOpen: () => {},
|
toggleOpen: () => {},
|
||||||
setOpen: (_open: boolean) => {},
|
setOpen: (_open: boolean) => {},
|
||||||
sendMessage: async (_content: string) => {},
|
sendMessage: async (_content: string) => {},
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ export interface ChatMessage {
|
|||||||
* turns that answered without running any tool.
|
* turns that answered without running any tool.
|
||||||
*/
|
*/
|
||||||
toolsUsed?: string[];
|
toolsUsed?: string[];
|
||||||
|
/**
|
||||||
|
* Full ordered progress log captured during the AI turn that produced this message.
|
||||||
|
* Only set on assistant messages; used to render the "Ran for X seconds" collapsed
|
||||||
|
* history dropdown above the response.
|
||||||
|
*/
|
||||||
|
progressLog?: AiWorkflowProgress[];
|
||||||
|
/** Wall-clock duration of the AI turn in milliseconds. Only set on assistant messages. */
|
||||||
|
durationMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AiWorkflowPhase {
|
export enum AiWorkflowPhase {
|
||||||
@@ -177,12 +185,20 @@ interface ChatState {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
progress: AiWorkflowProgress | null;
|
progress: AiWorkflowProgress | null;
|
||||||
|
/** Ordered log of every progress event in the current request. UI shows the last N entries. */
|
||||||
|
progressLog: AiWorkflowProgress[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of progress steps retained in the live buffer.
|
||||||
|
*/
|
||||||
|
export const PROGRESS_LOG_MAX = 4;
|
||||||
|
|
||||||
type ChatAction =
|
type ChatAction =
|
||||||
| { type: "ADD_MESSAGE"; message: ChatMessage }
|
| { type: "ADD_MESSAGE"; message: ChatMessage }
|
||||||
| { type: "SET_LOADING"; loading: boolean }
|
| { type: "SET_LOADING"; loading: boolean }
|
||||||
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
|
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
|
||||||
|
| { type: "APPEND_PROGRESS"; progress: AiWorkflowProgress }
|
||||||
| { type: "TOGGLE_OPEN" }
|
| { type: "TOGGLE_OPEN" }
|
||||||
| { type: "SET_OPEN"; open: boolean }
|
| { type: "SET_OPEN"; open: boolean }
|
||||||
| { type: "CLEAR" };
|
| { type: "CLEAR" };
|
||||||
@@ -192,15 +208,40 @@ function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
|||||||
case "ADD_MESSAGE":
|
case "ADD_MESSAGE":
|
||||||
return { ...state, messages: [...state.messages, action.message] };
|
return { ...state, messages: [...state.messages, action.message] };
|
||||||
case "SET_LOADING":
|
case "SET_LOADING":
|
||||||
return { ...state, isLoading: action.loading };
|
// Reset the log on both start (true) and end (false) of a request.
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLoading: action.loading,
|
||||||
|
progress: action.loading ? state.progress : null,
|
||||||
|
progressLog: [],
|
||||||
|
};
|
||||||
case "SET_PROGRESS":
|
case "SET_PROGRESS":
|
||||||
return { ...state, progress: action.progress };
|
return { ...state, progress: action.progress };
|
||||||
|
case "APPEND_PROGRESS":
|
||||||
|
// Cap the live buffer so each append copies at most PROGRESS_LOG_MAX elements
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
progress: action.progress,
|
||||||
|
progressLog:
|
||||||
|
state.progressLog.length < PROGRESS_LOG_MAX
|
||||||
|
? [...state.progressLog, action.progress]
|
||||||
|
: [
|
||||||
|
...state.progressLog.slice(1 - PROGRESS_LOG_MAX),
|
||||||
|
action.progress,
|
||||||
|
],
|
||||||
|
};
|
||||||
case "TOGGLE_OPEN":
|
case "TOGGLE_OPEN":
|
||||||
return { ...state, isOpen: !state.isOpen };
|
return { ...state, isOpen: !state.isOpen };
|
||||||
case "SET_OPEN":
|
case "SET_OPEN":
|
||||||
return { ...state, isOpen: action.open };
|
return { ...state, isOpen: action.open };
|
||||||
case "CLEAR":
|
case "CLEAR":
|
||||||
return { ...state, messages: [], isLoading: false, progress: null };
|
return {
|
||||||
|
...state,
|
||||||
|
messages: [],
|
||||||
|
isLoading: false,
|
||||||
|
progress: null,
|
||||||
|
progressLog: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +365,8 @@ interface ChatContextValue {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
progress: AiWorkflowProgress | null;
|
progress: AiWorkflowProgress | null;
|
||||||
|
/** Ordered log of every progress event for the current in-flight request. */
|
||||||
|
progressLog: AiWorkflowProgress[];
|
||||||
toggleOpen: () => void;
|
toggleOpen: () => void;
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
sendMessage: (content: string) => Promise<void>;
|
sendMessage: (content: string) => Promise<void>;
|
||||||
@@ -338,6 +381,7 @@ const initialState: ChatState = {
|
|||||||
isOpen: false,
|
isOpen: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
progress: null,
|
progress: null,
|
||||||
|
progressLog: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||||
@@ -439,6 +483,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
|
|
||||||
const priorMessages = messagesRef.current;
|
const priorMessages = messagesRef.current;
|
||||||
|
const startTime = Date.now();
|
||||||
|
// Mirror every progress event locally so we can attach the full log to
|
||||||
|
// the assistant message when the result arrives — without needing a ref
|
||||||
|
// into the reducer state.
|
||||||
|
const progressLogLocal: AiWorkflowProgress[] = [];
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const userMessage: ChatMessage = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -499,16 +548,15 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
) {
|
) {
|
||||||
toolsUsed.push(data.tool);
|
toolsUsed.push(data.tool);
|
||||||
}
|
}
|
||||||
dispatch({
|
const progressItem: AiWorkflowProgress = {
|
||||||
type: "SET_PROGRESS",
|
|
||||||
progress: {
|
|
||||||
phase: data.phase as AiWorkflowPhase,
|
phase: data.phase as AiWorkflowPhase,
|
||||||
tool: data.tool,
|
tool: data.tool,
|
||||||
stepIndex: data.stepIndex,
|
stepIndex: data.stepIndex,
|
||||||
stepCount: data.stepCount,
|
stepCount: data.stepCount,
|
||||||
engineDetail: data.engineDetail,
|
engineDetail: data.engineDetail,
|
||||||
},
|
};
|
||||||
});
|
progressLogLocal.push(progressItem);
|
||||||
|
dispatch({ type: "APPEND_PROGRESS", progress: progressItem });
|
||||||
},
|
},
|
||||||
onResult: (data) => {
|
onResult: (data) => {
|
||||||
receivedResult = true;
|
receivedResult = true;
|
||||||
@@ -522,6 +570,11 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
content: replyContent,
|
content: replyContent,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
|
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
|
||||||
|
progressLog:
|
||||||
|
progressLogLocal.length > 0
|
||||||
|
? [...progressLogLocal]
|
||||||
|
: undefined,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (data.fileId || data.resultFiles?.length) {
|
if (data.fileId || data.resultFiles?.length) {
|
||||||
@@ -595,6 +648,7 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
|||||||
isOpen: state.isOpen,
|
isOpen: state.isOpen,
|
||||||
isLoading: state.isLoading,
|
isLoading: state.isLoading,
|
||||||
progress: state.progress,
|
progress: state.progress,
|
||||||
|
progressLog: state.progressLog,
|
||||||
toggleOpen,
|
toggleOpen,
|
||||||
setOpen,
|
setOpen,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
|||||||
@@ -396,7 +396,182 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thinking indicator */
|
/* ─── Progress step log ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shown while the AI is working. Each step slides in from below as events
|
||||||
|
* arrive; the active (last) step is full-opacity with a pulsing icon, and
|
||||||
|
* earlier completed steps are dimmed. Only the most-recent N steps are
|
||||||
|
* rendered — there is no scrollable list.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.chat-progress-log {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.15rem 0.5rem 0.35rem;
|
||||||
|
/* No gap — the connector line div provides the inter-step spacing. */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-progress-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.65rem;
|
||||||
|
/*
|
||||||
|
* Use color rather than opacity for past-step dimming so the connector
|
||||||
|
* line (a sibling element inside __left, not a text node) is NOT affected
|
||||||
|
* and stays clearly visible. currentColor flows into SVG icon fills;
|
||||||
|
* color inherits into the label span automatically.
|
||||||
|
*/
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 250ms ease-out;
|
||||||
|
animation: chat-step-enter 300ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Active step: white text on dark mode so currentColor feeds directly into
|
||||||
|
* the StirlingLogoAnimated SVG paths and the label at the same time.
|
||||||
|
* Light-mode falls back to the default text colour so it's not invisible.
|
||||||
|
*/
|
||||||
|
/* Active step: icon and label are coloured independently so the logo can be
|
||||||
|
blue while the text uses the normal text colour. */
|
||||||
|
.chat-progress-step--active .chat-progress-step__icon {
|
||||||
|
color: var(--mantine-color-blue-filled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-progress-step--active .chat-progress-step__label {
|
||||||
|
color: var(--mantine-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme="dark"]
|
||||||
|
.chat-progress-step--active
|
||||||
|
.chat-progress-step__label {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-progress-step__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon cell — fixed height so the connector line aligns cleanly. */
|
||||||
|
.chat-progress-step__icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Inherits color from .chat-progress-step so currentColor flows into icons. */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Connector line between steps. Not a child of the label so it is unaffected
|
||||||
|
* by the step's color; it sits on its own and reads clearly against the
|
||||||
|
* toolbar background.
|
||||||
|
*/
|
||||||
|
.chat-progress-step__line {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--border-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active step label — slightly larger, inherits the active color. */
|
||||||
|
.chat-progress-step__label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding-top: 3px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Past step labels — a touch smaller so the active row reads as the primary. */
|
||||||
|
.chat-progress-step:not(.chat-progress-step--active)
|
||||||
|
.chat-progress-step__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wrapper that scales a registry tool icon (LocalIcon, typically 1.5rem/24px)
|
||||||
|
* down to fit the 20px icon column. Transform does not affect layout, so the
|
||||||
|
* parent overflow:hidden clips the un-scaled footprint cleanly.
|
||||||
|
*/
|
||||||
|
.chat-step-icon-scaled {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: scale(0.75);
|
||||||
|
transform-origin: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Entry: steps slide up from a few pixels below their final position. */
|
||||||
|
@keyframes chat-step-enter {
|
||||||
|
from {
|
||||||
|
transform: translateY(7px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Completed progress log dropdown ──────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shown above each completed assistant turn. The toggle displays a small
|
||||||
|
* "Ran for X seconds" label; expanding reveals the full ordered step list
|
||||||
|
* for that turn in a compact, read-only format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.chat-completed-log {
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-completed-log__toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.1rem 0.25rem 0.1rem 0.1rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background 120ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-completed-log__toggle:hover {
|
||||||
|
background: var(--mantine-color-default-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-completed-log__steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.35rem 0.4rem 0.2rem 0.25rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In the completed log every step is a past step — no active highlighting,
|
||||||
|
no entry animation (the content is static once opened). */
|
||||||
|
.chat-completed-log__steps .chat-progress-step {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tighten the icon and connector dimensions for the denser historical view. */
|
||||||
|
.chat-completed-log__steps .chat-progress-step__icon {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-completed-log__steps .chat-progress-step__line {
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-completed-log__steps .chat-progress-step__label {
|
||||||
|
font-size: 0.775rem;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legacy thinking indicator (kept in case any other code references it). */
|
||||||
.chat-thinking {
|
.chat-thinking {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -405,6 +580,13 @@
|
|||||||
color: var(--mantine-color-blue-filled);
|
color: var(--mantine-color-blue-filled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chat-progress-step {
|
||||||
|
animation: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Stirling logo thinking animation ─────────────────────────────────── */
|
/* ─── Stirling logo thinking animation ─────────────────────────────────── */
|
||||||
|
|
||||||
.stirling-thinking__path-right {
|
.stirling-thinking__path-right {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
|
import { renderMarkdown } from "@app/components/viewer/nonpdf/MarkdownRenderer";
|
||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
@@ -13,7 +14,6 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Collapse,
|
Collapse,
|
||||||
Group,
|
Group,
|
||||||
List,
|
|
||||||
Menu,
|
Menu,
|
||||||
Paper,
|
Paper,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
@@ -23,7 +23,10 @@ import {
|
|||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||||
|
import ArticleOutlinedIcon from "@mui/icons-material/ArticleOutlined";
|
||||||
|
import BuildOutlinedIcon from "@mui/icons-material/BuildOutlined";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
|
||||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||||
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||||
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||||
@@ -33,14 +36,15 @@ import {
|
|||||||
useChat,
|
useChat,
|
||||||
AiWorkflowPhase,
|
AiWorkflowPhase,
|
||||||
ChatRole,
|
ChatRole,
|
||||||
|
PROGRESS_LOG_MAX,
|
||||||
isKnownEngineProgressDetail,
|
isKnownEngineProgressDetail,
|
||||||
type AiWorkflowProgress,
|
type AiWorkflowProgress,
|
||||||
type AnyEngineProgressDetail,
|
type AnyEngineProgressDetail,
|
||||||
} from "@app/components/chat/ChatContext";
|
} from "@app/components/chat/ChatContext";
|
||||||
import { formatRelativeTime } from "@app/utils/timeUtils";
|
import { formatRelativeTime } from "@app/utils/timeUtils";
|
||||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
|
||||||
import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated";
|
import { StirlingLogoAnimated } from "@app/components/agents/StirlingLogoAnimated";
|
||||||
|
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||||
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
|
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
|
||||||
import "@app/components/chat/ChatPanel.css";
|
import "@app/components/chat/ChatPanel.css";
|
||||||
|
|
||||||
@@ -68,6 +72,27 @@ function useToolNameResolver(): ToolNameResolver {
|
|||||||
}, [allTools]);
|
}, [allTools]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolver mapping a tool endpoint path to its registry icon ReactNode. */
|
||||||
|
type ToolIconResolver = (endpoint: string) => ReactNode | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a tool's icon ReactNode from the tool catalog, keyed by API endpoint path.
|
||||||
|
* Returns null when the endpoint is not found (use a generic fallback icon in that case).
|
||||||
|
*/
|
||||||
|
function useToolIconResolver(): ToolIconResolver {
|
||||||
|
const { allTools } = useTranslatedToolCatalog();
|
||||||
|
return useMemo(() => {
|
||||||
|
const iconByEndpoint = new Map<string, ReactNode>();
|
||||||
|
Object.values(allTools).forEach((tool) => {
|
||||||
|
const endpoint = tool.operationConfig?.endpoint;
|
||||||
|
if (typeof endpoint === "string") {
|
||||||
|
iconByEndpoint.set(endpoint, tool.icon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (endpoint: string) => iconByEndpoint.get(endpoint) ?? null;
|
||||||
|
}, [allTools]);
|
||||||
|
}
|
||||||
|
|
||||||
function formatProgress(
|
function formatProgress(
|
||||||
progress: AiWorkflowProgress,
|
progress: AiWorkflowProgress,
|
||||||
t: TranslateFn,
|
t: TranslateFn,
|
||||||
@@ -125,31 +150,152 @@ function formatEngineProgress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolsUsedBlock({
|
/**
|
||||||
tools,
|
* Choose an icon for a progress step.
|
||||||
resolveToolName,
|
*
|
||||||
|
* The active (current) step always shows the animated Stirling logo so it reads
|
||||||
|
* as the "live" indicator. Past steps get a phase-specific icon so the trail
|
||||||
|
* is scannable at a glance.
|
||||||
|
*/
|
||||||
|
function progressStepIcon(
|
||||||
|
progress: AiWorkflowProgress,
|
||||||
|
resolveToolIcon: ToolIconResolver,
|
||||||
|
isActive: boolean,
|
||||||
|
): ReactNode {
|
||||||
|
if (isActive) {
|
||||||
|
return <StirlingLogoAnimated size={18} />;
|
||||||
|
}
|
||||||
|
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL) {
|
||||||
|
const registryIcon = progress.tool ? resolveToolIcon(progress.tool) : null;
|
||||||
|
if (registryIcon) {
|
||||||
|
return <span className="chat-step-icon-scaled">{registryIcon}</span>;
|
||||||
|
}
|
||||||
|
return <BuildOutlinedIcon sx={{ fontSize: 17 }} />;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
progress.phase === AiWorkflowPhase.EXTRACTING_CONTENT ||
|
||||||
|
progress.phase === AiWorkflowPhase.ENGINE_PROGRESS
|
||||||
|
) {
|
||||||
|
return <ArticleOutlinedIcon sx={{ fontSize: 17 }} />;
|
||||||
|
}
|
||||||
|
return <CloudOutlinedIcon sx={{ fontSize: 17 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated step-by-step progress log shown while the AI is working.
|
||||||
|
* Displays the last {@link PROGRESS_LOG_VISIBLE} steps from the live event stream,
|
||||||
|
* with the active (most recent) step highlighted and older steps dimmed.
|
||||||
|
*/
|
||||||
|
function ProgressLogDisplay({
|
||||||
|
progressLog,
|
||||||
t,
|
t,
|
||||||
|
resolveToolName,
|
||||||
|
resolveToolIcon,
|
||||||
}: {
|
}: {
|
||||||
tools: string[];
|
progressLog: AiWorkflowProgress[];
|
||||||
resolveToolName: ToolNameResolver;
|
|
||||||
t: TranslateFn;
|
t: TranslateFn;
|
||||||
|
resolveToolName: ToolNameResolver;
|
||||||
|
resolveToolIcon: ToolIconResolver;
|
||||||
|
}) {
|
||||||
|
// Placeholder shown before the first SSE event arrives.
|
||||||
|
if (progressLog.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="chat-progress-log">
|
||||||
|
<div className="chat-progress-step chat-progress-step--active">
|
||||||
|
<div className="chat-progress-step__left">
|
||||||
|
<div className="chat-progress-step__icon">
|
||||||
|
<StirlingLogoAnimated size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="chat-progress-step__label">
|
||||||
|
{t("chat.progress.thinking")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chronological order: oldest at top, newest (active) at bottom.
|
||||||
|
// The reducer already caps progressLog at PROGRESS_LOG_MAX entries, so this
|
||||||
|
// slice is effectively a no-op but kept for defensive correctness.
|
||||||
|
const visibleSteps = progressLog.slice(-PROGRESS_LOG_MAX);
|
||||||
|
const startIndex = progressLog.length - visibleSteps.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chat-progress-log">
|
||||||
|
{visibleSteps.map((step, i) => {
|
||||||
|
// Stable key based on absolute position in the full log — React reuses
|
||||||
|
// existing DOM elements and only mounts (and animates) new ones.
|
||||||
|
const globalIndex = startIndex + i;
|
||||||
|
const isActive = i === visibleSteps.length - 1; // last = newest = bottom
|
||||||
|
// Connector runs below every step except the active one at the bottom.
|
||||||
|
const showConnector = i < visibleSteps.length - 1;
|
||||||
|
const label = formatProgress(step, t, resolveToolName);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={globalIndex}
|
||||||
|
className={`chat-progress-step${isActive ? " chat-progress-step--active" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="chat-progress-step__left">
|
||||||
|
<div className="chat-progress-step__icon">
|
||||||
|
{progressStepIcon(step, resolveToolIcon, isActive)}
|
||||||
|
</div>
|
||||||
|
{showConnector && <div className="chat-progress-step__line" />}
|
||||||
|
</div>
|
||||||
|
<span className="chat-progress-step__label">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number, t: TranslateFn): string {
|
||||||
|
const totalSeconds = Math.max(1, Math.round(ms / 1000));
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (minutes === 0) {
|
||||||
|
return t("chat.progress.ranForSeconds", { count: totalSeconds });
|
||||||
|
}
|
||||||
|
if (seconds === 0) {
|
||||||
|
return t("chat.progress.ranForMinutes", { count: minutes });
|
||||||
|
}
|
||||||
|
return t("chat.progress.ranForMinutesSeconds", { minutes, seconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapsed "Ran for X seconds" dropdown that appears above each completed
|
||||||
|
* assistant turn. Expands to show the full ordered progress log for that turn.
|
||||||
|
*/
|
||||||
|
function CompletedProgressLogDropdown({
|
||||||
|
progressLog,
|
||||||
|
durationMs,
|
||||||
|
t,
|
||||||
|
resolveToolName,
|
||||||
|
resolveToolIcon,
|
||||||
|
}: {
|
||||||
|
progressLog: AiWorkflowProgress[];
|
||||||
|
durationMs: number;
|
||||||
|
t: TranslateFn;
|
||||||
|
resolveToolName: ToolNameResolver;
|
||||||
|
resolveToolIcon: ToolIconResolver;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const names = tools.map(
|
const label = formatDuration(durationMs, t);
|
||||||
(endpoint) => resolveToolName(endpoint) ?? t("chat.toolsUsed.unknownTool"),
|
|
||||||
);
|
|
||||||
const label = t("chat.toolsUsed.summary", { count: tools.length });
|
|
||||||
return (
|
return (
|
||||||
<Box mt={6}>
|
<div className="chat-completed-log">
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
|
className="chat-completed-log__toggle"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
>
|
>
|
||||||
<Group gap={4} wrap="nowrap">
|
<Group gap={4} wrap="nowrap" align="center">
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ExpandLessIcon sx={{ fontSize: 14 }} />
|
<ExpandLessIcon sx={{ fontSize: 12 }} />
|
||||||
) : (
|
) : (
|
||||||
<ExpandMoreIcon sx={{ fontSize: 14 }} />
|
<ExpandMoreIcon sx={{ fontSize: 12 }} />
|
||||||
)}
|
)}
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{label}
|
{label}
|
||||||
@@ -157,19 +303,30 @@ function ToolsUsedBlock({
|
|||||||
</Group>
|
</Group>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
<Collapse in={expanded}>
|
<Collapse in={expanded}>
|
||||||
<List
|
<div className="chat-completed-log__steps">
|
||||||
type="ordered"
|
{progressLog.map((step, i) => {
|
||||||
size="xs"
|
const showConnector = i < progressLog.length - 1;
|
||||||
mt={4}
|
const stepLabel = formatProgress(step, t, resolveToolName);
|
||||||
pl="lg"
|
return (
|
||||||
styles={{ itemWrapper: { lineHeight: 1.4 } }}
|
<div
|
||||||
|
key={i}
|
||||||
|
className="chat-progress-step chat-progress-step--done"
|
||||||
>
|
>
|
||||||
{names.map((name, i) => (
|
<div className="chat-progress-step__left">
|
||||||
<List.Item key={i}>{name}</List.Item>
|
<div className="chat-progress-step__icon">
|
||||||
))}
|
{progressStepIcon(step, resolveToolIcon, false)}
|
||||||
</List>
|
</div>
|
||||||
|
{showConnector && (
|
||||||
|
<div className="chat-progress-step__line" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="chat-progress-step__label">{stepLabel}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Box>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,15 +334,19 @@ function ChatMessageBubble({
|
|||||||
role,
|
role,
|
||||||
content,
|
content,
|
||||||
timestamp,
|
timestamp,
|
||||||
toolsUsed,
|
progressLog,
|
||||||
|
durationMs,
|
||||||
resolveToolName,
|
resolveToolName,
|
||||||
|
resolveToolIcon,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
role: ChatRole;
|
role: ChatRole;
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
toolsUsed?: string[];
|
progressLog?: AiWorkflowProgress[];
|
||||||
|
durationMs?: number;
|
||||||
resolveToolName: ToolNameResolver;
|
resolveToolName: ToolNameResolver;
|
||||||
|
resolveToolIcon: ToolIconResolver;
|
||||||
t: TranslateFn;
|
t: TranslateFn;
|
||||||
}) {
|
}) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -231,16 +392,18 @@ function ChatMessageBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="chat-message chat-message-assistant">
|
<div className="chat-message chat-message-assistant">
|
||||||
<div className="chat-bubble-assistant">
|
<div className="chat-bubble-assistant">
|
||||||
|
{progressLog && progressLog.length > 0 && durationMs != null && (
|
||||||
|
<CompletedProgressLogDropdown
|
||||||
|
progressLog={progressLog}
|
||||||
|
durationMs={durationMs}
|
||||||
|
t={t}
|
||||||
|
resolveToolName={resolveToolName}
|
||||||
|
resolveToolIcon={resolveToolIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Text size="sm" component="div">
|
<Text size="sm" component="div">
|
||||||
{renderMarkdown(content)}
|
{renderMarkdown(content)}
|
||||||
</Text>
|
</Text>
|
||||||
{toolsUsed && toolsUsed.length > 0 && (
|
|
||||||
<ToolsUsedBlock
|
|
||||||
tools={toolsUsed}
|
|
||||||
resolveToolName={resolveToolName}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{actions}
|
{actions}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,20 +419,55 @@ export interface ChatPanelProps {
|
|||||||
|
|
||||||
export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { messages, isLoading, progress, sendMessage, clearChat } = useChat();
|
const { messages, isLoading, progressLog, sendMessage, clearChat } =
|
||||||
|
useChat();
|
||||||
const resolveToolName = useToolNameResolver();
|
const resolveToolName = useToolNameResolver();
|
||||||
|
const resolveToolIcon = useToolIconResolver();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
// Tracks whether the user manually scrolled away from the bottom.
|
||||||
|
// A ref (not state) so scroll events don't cause re-renders.
|
||||||
|
const userScrolledUp = useRef(false);
|
||||||
|
|
||||||
|
// Jump to the bottom on first render so existing conversations open at the
|
||||||
|
// most recent message rather than the top.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
requestAnimationFrame(() => {
|
||||||
scrollRef.current.scrollTo({
|
const el = scrollRef.current;
|
||||||
top: scrollRef.current.scrollHeight,
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
behavior: "smooth",
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Attach a passive scroll listener to track whether the user has scrolled
|
||||||
|
// away from the bottom (breaks auto-scroll) or returned to it (re-latches).
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
userScrolledUp.current = distFromBottom > 50;
|
||||||
|
};
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scroll to the bottom when messages arrive or live progress steps update,
|
||||||
|
// unless the user has scrolled up (they're reading history).
|
||||||
|
// Scrolling back to the bottom resets the ref, so the next update re-latches.
|
||||||
|
//
|
||||||
|
// RAF defers the scroll until after the browser has laid out the new nodes,
|
||||||
|
// so scrollHeight is correct. Direct scrollTop assignment avoids the
|
||||||
|
// smooth-scroll interruption problem that occurs when SSE events arrive
|
||||||
|
// faster than a smooth animation can complete.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userScrolledUp.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [messages]);
|
}, [messages, progressLog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -343,21 +541,21 @@ export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
|||||||
role={msg.role}
|
role={msg.role}
|
||||||
content={msg.content}
|
content={msg.content}
|
||||||
timestamp={msg.timestamp}
|
timestamp={msg.timestamp}
|
||||||
toolsUsed={msg.toolsUsed}
|
progressLog={msg.progressLog}
|
||||||
|
durationMs={msg.durationMs}
|
||||||
resolveToolName={resolveToolName}
|
resolveToolName={resolveToolName}
|
||||||
|
resolveToolIcon={resolveToolIcon}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="chat-message chat-message-assistant">
|
<div className="chat-message chat-message-assistant">
|
||||||
<div className="chat-thinking">
|
<ProgressLogDisplay
|
||||||
<StirlingLogoAnimated size={20} />
|
progressLog={progressLog}
|
||||||
<Text size="sm" c="dimmed">
|
t={t}
|
||||||
{progress
|
resolveToolName={resolveToolName}
|
||||||
? formatProgress(progress, t, resolveToolName)
|
resolveToolIcon={resolveToolIcon}
|
||||||
: t("chat.progress.thinking")}
|
/>
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user