mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 02:54:06 +02:00
feat: add Agents UI to proprietary right sidebar (#6454)
Update UI to include agents Run `task dev:all` to test
This commit is contained in:
@@ -8,6 +8,7 @@ import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
||||
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
|
||||
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
|
||||
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
|
||||
import { ChatProvider } from "@app/components/chat/ChatContext";
|
||||
|
||||
export function AppProviders({
|
||||
children,
|
||||
@@ -25,7 +26,7 @@ export function AppProviders({
|
||||
<ServerExperienceProvider>
|
||||
<CheckoutProvider>
|
||||
<UpgradeBannerInitializer />
|
||||
{children}
|
||||
<ChatProvider>{children}</ChatProvider>
|
||||
</CheckoutProvider>
|
||||
</ServerExperienceProvider>
|
||||
</UpdateSeatsProvider>
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
|
||||
|
||||
.agents-takeover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-toolbar);
|
||||
z-index: 5;
|
||||
view-transition-name: agents-rail;
|
||||
}
|
||||
|
||||
/* ─── Sidebar agents section ─────────────────────────────────────────────── */
|
||||
|
||||
.agents-section {
|
||||
padding: 0.5rem 0.75rem 0.875rem;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-toolbar);
|
||||
flex-shrink: 0;
|
||||
view-transition-name: agents-rail;
|
||||
}
|
||||
|
||||
/* Hero button — real Stirling agent */
|
||||
.agent-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--mantine-color-blue-5) 40%, var(--border-subtle));
|
||||
border-radius: 0.625rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-6) 6%,
|
||||
var(--mantine-color-body)
|
||||
)
|
||||
0%,
|
||||
var(--mantine-color-body) 100%
|
||||
);
|
||||
transition:
|
||||
background 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.agent-button:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 65%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-6) 12%,
|
||||
var(--mantine-color-default-hover)
|
||||
)
|
||||
0%,
|
||||
var(--mantine-color-default-hover) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.agent-button__logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Coming-soon agent rows */
|
||||
.agents-sidebar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.agent-button--coming-soon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.65;
|
||||
transition:
|
||||
opacity 180ms ease-out,
|
||||
transform 180ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.agent-button--coming-soon {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
}
|
||||
|
||||
.agent-button--coming-soon:hover {
|
||||
opacity: 0.85;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-gray-4) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.agent-button__icon-plain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
color: var(--mantine-color-dimmed);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agents-view-all {
|
||||
all: unset;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
border-radius: 0.375rem;
|
||||
transition: opacity 120ms ease-out;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.agents-view-all:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dark mode — sidebar */
|
||||
[data-mantine-color-scheme="dark"] .agent-button--coming-soon {
|
||||
background: transparent;
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-dark-4) 60%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button--coming-soon:hover {
|
||||
border-color: var(--mantine-color-dark-3);
|
||||
background: var(--mantine-color-dark-6);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-7) 55%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-9) 35%, transparent) 0%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-5) 60%,
|
||||
var(--border-subtle)
|
||||
);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-8) 45%,
|
||||
rgba(255, 255, 255, 0.04)
|
||||
)
|
||||
0%,
|
||||
rgba(255, 255, 255, 0.04) 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agent-button__logo {
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* ─── Collapsed-rail agent button ────────────────────────────────────────── */
|
||||
|
||||
.agents-collapsed-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--mantine-color-blue-filled);
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease-out;
|
||||
}
|
||||
|
||||
.agents-collapsed-btn:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
/* ─── Fullscreen hero card ───────────────────────────────────────────────── */
|
||||
|
||||
/*
|
||||
* .tool-panel__fullscreen-group--agents gives the gradient border via the
|
||||
* padding-box / border-box trick. We only override the padding-box with a
|
||||
* very light tint so the card doesn't read as a colourful surface.
|
||||
*/
|
||||
.agents-hero {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
border-radius: 1rem;
|
||||
background:
|
||||
linear-gradient(
|
||||
125deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-1) 35%, white) 0%,
|
||||
color-mix(in srgb, var(--mantine-color-violet-1) 45%, white) 50%,
|
||||
color-mix(in srgb, var(--mantine-color-pink-0) 35%, white) 100%
|
||||
)
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
}
|
||||
|
||||
/* Body: left CTA + right grid */
|
||||
.agents-hero__body {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Left CTA — plain content, only the button inside is interactive ── */
|
||||
.agents-hero__cta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex: 0 0 18rem;
|
||||
box-sizing: border-box;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.agents-hero__cta-logo {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.agents-hero__cta-headline {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn {
|
||||
all: unset;
|
||||
margin-top: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4375rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--mantine-color-blue-6);
|
||||
color: white;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn:hover {
|
||||
background: var(--mantine-color-blue-7);
|
||||
}
|
||||
|
||||
.agents-hero__cta-btn:focus-visible {
|
||||
outline: 2px solid var(--mantine-color-blue-5);
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Right 2×3 grid — sizes to content, pushed to the right ── */
|
||||
.agents-hero__grid {
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: grid;
|
||||
/* auto columns: each column = widest item in that column */
|
||||
grid-template-columns: repeat(2, auto);
|
||||
gap: 0.625rem;
|
||||
align-content: start;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
/* Solid white cards — no opacity so the white is opaque */
|
||||
.agents-hero__grid-item {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.07);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: not-allowed;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.agents-hero__grid-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--mantine-color-blue-5);
|
||||
}
|
||||
|
||||
.agents-hero__grid-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Dark mode — fullscreen ── */
|
||||
[data-mantine-color-scheme="dark"] .agents-hero {
|
||||
background:
|
||||
linear-gradient(
|
||||
125deg,
|
||||
color-mix(in srgb, var(--mantine-color-blue-9) 55%, #1a1b2e) 0%,
|
||||
color-mix(in srgb, var(--mantine-color-violet-9) 45%, #1a1b2e) 50%,
|
||||
color-mix(in srgb, var(--mantine-color-pink-9) 40%, #1a1b2e) 100%
|
||||
)
|
||||
padding-box,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
var(--mantine-color-blue-6) 0%,
|
||||
var(--mantine-color-violet-6) 45%,
|
||||
var(--mantine-color-pink-6) 100%
|
||||
)
|
||||
border-box;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__cta-logo {
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__grid-item {
|
||||
background: var(--fullscreen-bg-item);
|
||||
border-color: var(--fullscreen-border-subtle-70);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .agents-hero__grid-icon {
|
||||
color: var(--mantine-color-blue-3);
|
||||
}
|
||||
|
||||
/* ─── agents-rail view transition — slide up ─────────────────────────────── */
|
||||
|
||||
::view-transition-old(agents-rail) {
|
||||
animation: vt-agents-out 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
::view-transition-new(agents-rail) {
|
||||
animation: vt-agents-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes vt-agents-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vt-agents-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-old(agents-rail),
|
||||
::view-transition-new(agents-rail) {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { Box, Group, Text, UnstyledButton } from "@mantine/core";
|
||||
import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded";
|
||||
import SummarizeRoundedIcon from "@mui/icons-material/SummarizeRounded";
|
||||
import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded";
|
||||
import GavelRoundedIcon from "@mui/icons-material/GavelRounded";
|
||||
import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded";
|
||||
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useChat } from "@app/components/chat/ChatContext";
|
||||
import { ChatPanel } from "@app/components/chat/ChatPanel";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||
import { withViewTransition } from "@app/utils/viewTransition";
|
||||
import "@app/components/agents/AgentsPanel.css";
|
||||
|
||||
interface ComingSoonAgent {
|
||||
id: string;
|
||||
nameKey: string;
|
||||
descriptionKey: string;
|
||||
Icon: ElementType;
|
||||
}
|
||||
|
||||
const COMING_SOON_AGENTS: ComingSoonAgent[] = [
|
||||
{
|
||||
id: "data-extraction",
|
||||
nameKey: "agents.data_extraction_name",
|
||||
descriptionKey: "agents.data_extraction_description",
|
||||
Icon: TableChartRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "doc-summary",
|
||||
nameKey: "agents.doc_summary_name",
|
||||
descriptionKey: "agents.doc_summary_description",
|
||||
Icon: SummarizeRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "auto-redaction",
|
||||
nameKey: "agents.auto_redaction_name",
|
||||
descriptionKey: "agents.auto_redaction_description",
|
||||
Icon: VisibilityOffRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "compliance",
|
||||
nameKey: "agents.compliance_name",
|
||||
descriptionKey: "agents.compliance_description",
|
||||
Icon: GavelRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "form-filler",
|
||||
nameKey: "agents.form_filler_name",
|
||||
descriptionKey: "agents.form_filler_description",
|
||||
Icon: AssignmentRoundedIcon,
|
||||
},
|
||||
{
|
||||
id: "pdf-to-markdown",
|
||||
nameKey: "agents.pdf_to_markdown_name",
|
||||
descriptionKey: "agents.pdf_to_markdown_description",
|
||||
Icon: CodeRoundedIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export function useAgentsEnabled(): boolean {
|
||||
const { config } = useAppConfig();
|
||||
return Boolean(config?.aiEngineEnabled);
|
||||
}
|
||||
|
||||
export function useAgentChatOpen(): boolean {
|
||||
const { isOpen } = useChat();
|
||||
return isOpen;
|
||||
}
|
||||
|
||||
const PREVIEW_COUNT = 3;
|
||||
|
||||
/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */
|
||||
export function AgentsSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
if (!enabled || isOpen) return null;
|
||||
|
||||
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
|
||||
const visibleAgents = showAll
|
||||
? COMING_SOON_AGENTS
|
||||
: COMING_SOON_AGENTS.slice(0, PREVIEW_COUNT);
|
||||
|
||||
return (
|
||||
<Box className="agents-section" w="100%">
|
||||
{/* Main Stirling agent — real and clickable */}
|
||||
<UnstyledButton
|
||||
className="agent-button agent-button--hero"
|
||||
onClick={() => withViewTransition(() => setOpen(true))}
|
||||
aria-label={t("agents.stirling_name", "Stirling")}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box className="agent-button__logo">
|
||||
<StirlingLogoOutline size={28} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{t("agents.stirling_name", "Stirling")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(
|
||||
"agents.stirling_description",
|
||||
"Your general-purpose PDF assistant",
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{/* Coming-soon agents */}
|
||||
<div className="agents-sidebar-list">
|
||||
{visibleAgents.map(({ id, nameKey, descriptionKey, Icon }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={comingSoonLabel}
|
||||
position="left"
|
||||
arrow
|
||||
delay={0}
|
||||
>
|
||||
<UnstyledButton
|
||||
className="agent-button agent-button--coming-soon"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box className="agent-button__icon-plain">
|
||||
<Icon sx={{ fontSize: "1.1rem" }} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t(nameKey)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(descriptionKey)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!showAll ? (
|
||||
<button
|
||||
type="button"
|
||||
className="agents-view-all"
|
||||
onClick={() => withViewTransition(() => setShowAll(true))}
|
||||
>
|
||||
{t("agents.view_all", "View all agents")} →
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="agents-view-all"
|
||||
onClick={() => withViewTransition(() => setShowAll(false))}
|
||||
>
|
||||
{t("agents.show_less", "Show less")} ↑
|
||||
</button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Icon-only agent button in the collapsed (minimised) right rail. */
|
||||
export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const label = t("agents.stirling_tooltip", "Stirling agent");
|
||||
|
||||
return (
|
||||
<AppTooltip content={label} position="left" arrow delay={300}>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
onExpand();
|
||||
setOpen(true);
|
||||
}}
|
||||
aria-label={label}
|
||||
className="agents-collapsed-btn"
|
||||
>
|
||||
<StirlingLogoOutline size={22} />
|
||||
</UnstyledButton>
|
||||
</AppTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen hero card — Stirling CTA on the left, 2×3 coming-soon grid on
|
||||
* the right. Matches the gradient border of the other fullscreen category cards.
|
||||
*/
|
||||
export function AgentsFullscreenSection() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled || isOpen) return null;
|
||||
|
||||
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
|
||||
|
||||
return (
|
||||
<section
|
||||
className="agents-hero tool-panel__fullscreen-group--agents"
|
||||
aria-label={t("agents.section_title", "Agents")}
|
||||
>
|
||||
<div className="agents-hero__body">
|
||||
{/* Left: Stirling content — only the button is interactive */}
|
||||
<div className="agents-hero__cta">
|
||||
<div className="agents-hero__cta-logo">
|
||||
<StirlingLogoOutline size={36} />
|
||||
</div>
|
||||
<Text className="agents-hero__cta-headline" fw={700} lh={1.2} mt="xs">
|
||||
{t("agents.stirling_full_name", "Stirling General Agent")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={8} lh={1.55}>
|
||||
{t(
|
||||
"agents.stirling_long_description",
|
||||
"General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents.",
|
||||
)}
|
||||
</Text>
|
||||
<button
|
||||
type="button"
|
||||
className="agents-hero__cta-btn"
|
||||
onClick={() => withViewTransition(() => setOpen(true))}
|
||||
>
|
||||
{t("agents.start_chat", "Start chatting")} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: 2×3 grid of coming-soon agents */}
|
||||
<div className="agents-hero__grid">
|
||||
{COMING_SOON_AGENTS.map(({ id, nameKey, descriptionKey, Icon }) => (
|
||||
<AppTooltip
|
||||
key={id}
|
||||
content={comingSoonLabel}
|
||||
position="top"
|
||||
arrow
|
||||
delay={0}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="agents-hero__grid-item"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<span className="agents-hero__grid-icon">
|
||||
<Icon sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
<div className="agents-hero__grid-body">
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{t(nameKey)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(descriptionKey)}
|
||||
</Text>
|
||||
</div>
|
||||
</button>
|
||||
</AppTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-rail chat overlay rendered inside ToolPanel. */
|
||||
export function AgentsChatOverlay() {
|
||||
const { t } = useTranslation();
|
||||
const { isOpen, setOpen } = useChat();
|
||||
const enabled = useAgentsEnabled();
|
||||
|
||||
if (!enabled || !isOpen) return null;
|
||||
|
||||
return (
|
||||
<Box className="agents-takeover">
|
||||
<ChatPanel
|
||||
onBack={() => withViewTransition(() => setOpen(false))}
|
||||
backLabel={t("agents.back_to_tools", "Back to tools")}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* A temp stirling logo, may change in future.
|
||||
*/
|
||||
export function StirlingLogoOutline({ size = 20 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 192 192"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<path d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useReducer,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { generateId } from "@app/utils/generateId";
|
||||
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { getAuthHeaders } from "@app/services/apiClientSetup";
|
||||
import { createChildStub } from "@app/contexts/file/fileActions";
|
||||
import {
|
||||
createNewStirlingFileStub,
|
||||
createStirlingFile,
|
||||
type StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { ToolOperation } from "@app/types/file";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: number;
|
||||
/**
|
||||
* Tool endpoint paths executed during this assistant turn (e.g.
|
||||
* {@code /api/v1/general/rotate-pdf}). Populated for assistant messages when the workflow
|
||||
* ran one or more tools, in execution order. Undefined for user messages and for assistant
|
||||
* turns that answered without running any tool.
|
||||
*/
|
||||
toolsUsed?: string[];
|
||||
}
|
||||
|
||||
export enum AiWorkflowPhase {
|
||||
ANALYZING = "analyzing",
|
||||
CALLING_ENGINE = "calling_engine",
|
||||
EXTRACTING_CONTENT = "extracting_content",
|
||||
EXECUTING_TOOL = "executing_tool",
|
||||
PROCESSING = "processing",
|
||||
ENGINE_PROGRESS = "engine_progress",
|
||||
}
|
||||
|
||||
/**
|
||||
* Engine-side progress detail for ENGINE_PROGRESS events. Mirrors the Python
|
||||
* {@code ProgressEvent} discriminated union (engine/src/stirling/contracts/progress.py)
|
||||
* and the Java {@code AiEngineProgressDetail} sealed interface; the {@code phase}
|
||||
* string is the discriminator. Field names are camelCase because the engine
|
||||
* serialises by alias.
|
||||
*/
|
||||
export interface WholeDocReadStartedDetail {
|
||||
phase: "whole_doc_read_started";
|
||||
question: string;
|
||||
pages: number;
|
||||
slices: number;
|
||||
}
|
||||
|
||||
export interface WholeDocSliceDoneDetail {
|
||||
phase: "whole_doc_slice_done";
|
||||
completed: number;
|
||||
total: number;
|
||||
/** Page-range label, e.g. "pages=1-5". */
|
||||
pages: string;
|
||||
durationMs: number;
|
||||
excerpts: number;
|
||||
facts: number;
|
||||
}
|
||||
|
||||
export interface WholeDocCompressionRoundDetail {
|
||||
phase: "whole_doc_compression_round";
|
||||
roundNumber: number;
|
||||
notesIn: number;
|
||||
groups: number;
|
||||
}
|
||||
|
||||
export interface WholeDocReadDoneDetail {
|
||||
phase: "whole_doc_read_done";
|
||||
completed: number;
|
||||
slices: number;
|
||||
durationSeconds: number;
|
||||
}
|
||||
|
||||
export type EngineProgressDetail =
|
||||
| WholeDocReadStartedDetail
|
||||
| WholeDocSliceDoneDetail
|
||||
| WholeDocCompressionRoundDetail
|
||||
| WholeDocReadDoneDetail;
|
||||
|
||||
/**
|
||||
* What we actually carry across the wire boundary: a known typed variant, or a
|
||||
* forward-compat shape with just the discriminator string. The "unknown" arm
|
||||
* exists so a new engine-side phase rolling out before a frontend update keeps
|
||||
* rendering the generic processing message instead of crashing the union.
|
||||
*/
|
||||
export interface UnknownEngineProgressDetail {
|
||||
phase: string;
|
||||
}
|
||||
|
||||
export type AnyEngineProgressDetail =
|
||||
| EngineProgressDetail
|
||||
| UnknownEngineProgressDetail;
|
||||
|
||||
const KNOWN_ENGINE_PHASES = new Set<string>([
|
||||
"whole_doc_read_started",
|
||||
"whole_doc_slice_done",
|
||||
"whole_doc_compression_round",
|
||||
"whole_doc_read_done",
|
||||
]);
|
||||
|
||||
export function isKnownEngineProgressDetail(
|
||||
detail: AnyEngineProgressDetail,
|
||||
): detail is EngineProgressDetail {
|
||||
return KNOWN_ENGINE_PHASES.has(detail.phase);
|
||||
}
|
||||
|
||||
export interface AiWorkflowProgress {
|
||||
phase: AiWorkflowPhase;
|
||||
/** Tool endpoint path currently executing, for EXECUTING_TOOL events. */
|
||||
tool?: string;
|
||||
/** 1-based step index, for EXECUTING_TOOL events. */
|
||||
stepIndex?: number;
|
||||
/** Total number of plan steps, for EXECUTING_TOOL events. */
|
||||
stepCount?: number;
|
||||
/**
|
||||
* Engine-side event payload, for ENGINE_PROGRESS events. Typed sub-phase
|
||||
* record (e.g. {@link WholeDocSliceDoneDetail}) the UI can render in detail.
|
||||
*/
|
||||
engineDetail?: AnyEngineProgressDetail;
|
||||
}
|
||||
|
||||
type AiWorkflowOutcome =
|
||||
| "answer"
|
||||
| "not_found"
|
||||
| "need_content"
|
||||
| "plan"
|
||||
| "need_clarification"
|
||||
| "cannot_do"
|
||||
| "tool_call"
|
||||
| "completed"
|
||||
| "unsupported_capability"
|
||||
| "cannot_continue";
|
||||
|
||||
interface AiWorkflowResultFile {
|
||||
/** Stirling file ID — download with /api/v1/general/files/{fileId}. */
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
interface AiWorkflowResponse {
|
||||
outcome: AiWorkflowOutcome;
|
||||
answer?: string;
|
||||
summary?: string;
|
||||
rationale?: string;
|
||||
reason?: string;
|
||||
question?: string;
|
||||
capability?: string;
|
||||
message?: string;
|
||||
evidence?: Array<{ pageNumber: number; text: string }>;
|
||||
steps?: Array<Record<string, unknown>>;
|
||||
/** Every file produced by the workflow (empty if the outcome has no files). */
|
||||
resultFiles?: AiWorkflowResultFile[];
|
||||
// Legacy single-file fields — mirror the first entry of resultFiles.
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: ChatMessage[];
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
progress: AiWorkflowProgress | null;
|
||||
}
|
||||
|
||||
type ChatAction =
|
||||
| { type: "ADD_MESSAGE"; message: ChatMessage }
|
||||
| { type: "SET_LOADING"; loading: boolean }
|
||||
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
|
||||
| { type: "TOGGLE_OPEN" }
|
||||
| { type: "SET_OPEN"; open: boolean }
|
||||
| { type: "CLEAR" };
|
||||
|
||||
function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
||||
switch (action.type) {
|
||||
case "ADD_MESSAGE":
|
||||
return { ...state, messages: [...state.messages, action.message] };
|
||||
case "SET_LOADING":
|
||||
return { ...state, isLoading: action.loading };
|
||||
case "SET_PROGRESS":
|
||||
return { ...state, progress: action.progress };
|
||||
case "TOGGLE_OPEN":
|
||||
return { ...state, isOpen: !state.isOpen };
|
||||
case "SET_OPEN":
|
||||
return { ...state, isOpen: action.open };
|
||||
case "CLEAR":
|
||||
return { ...state, messages: [], isLoading: false, progress: null };
|
||||
}
|
||||
}
|
||||
|
||||
type TranslateFn = ReturnType<typeof useTranslation>["t"];
|
||||
|
||||
function formatWorkflowResponse(
|
||||
data: AiWorkflowResponse,
|
||||
t: TranslateFn,
|
||||
): string {
|
||||
switch (data.outcome) {
|
||||
case "answer":
|
||||
case "completed":
|
||||
return data.answer ?? data.summary ?? t("chat.responses.done");
|
||||
case "need_clarification":
|
||||
return data.question ?? t("chat.responses.need_clarification");
|
||||
case "cannot_do":
|
||||
return data.reason ?? t("chat.responses.cannot_do");
|
||||
case "not_found":
|
||||
return data.reason ?? t("chat.responses.not_found");
|
||||
case "unsupported_capability":
|
||||
return (
|
||||
data.message ??
|
||||
t("chat.responses.unsupported_capability", {
|
||||
capability: data.capability ?? "unknown",
|
||||
})
|
||||
);
|
||||
case "cannot_continue":
|
||||
return data.reason ?? t("chat.responses.cannot_continue");
|
||||
case "plan":
|
||||
return data.rationale
|
||||
? `${data.rationale}\n\n${(data.steps ?? []).map((s, i) => `${i + 1}. ${JSON.stringify(s)}`).join("\n")}`
|
||||
: JSON.stringify(data.steps, null, 2);
|
||||
case "need_content":
|
||||
case "tool_call":
|
||||
return (
|
||||
data.rationale ??
|
||||
data.summary ??
|
||||
t("chat.responses.processing", { outcome: data.outcome })
|
||||
);
|
||||
default:
|
||||
return (
|
||||
data.answer ?? data.summary ?? data.message ?? JSON.stringify(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an SSE text stream and invokes callbacks for each named event.
|
||||
*/
|
||||
interface ProgressEvent {
|
||||
phase: string;
|
||||
timestamp: number;
|
||||
tool?: string;
|
||||
stepIndex?: number;
|
||||
stepCount?: number;
|
||||
engineDetail?: AnyEngineProgressDetail;
|
||||
}
|
||||
|
||||
async function consumeSSEStream(
|
||||
response: Response,
|
||||
handlers: {
|
||||
onProgress: (data: ProgressEvent) => void;
|
||||
onResult: (data: AiWorkflowResponse) => void;
|
||||
onError: (data: { message: string }) => void;
|
||||
},
|
||||
) {
|
||||
if (!response.body) {
|
||||
throw new Error("Response body is null");
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let currentEvent = "";
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// SSE frames are separated by double newlines
|
||||
let boundary = buffer.indexOf("\n\n");
|
||||
while (boundary !== -1) {
|
||||
const frame = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
let dataPayload = "";
|
||||
for (const line of frame.split("\n")) {
|
||||
if (line.startsWith("event:")) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataPayload += line.slice(5);
|
||||
}
|
||||
}
|
||||
|
||||
if (dataPayload) {
|
||||
try {
|
||||
const parsed = JSON.parse(dataPayload);
|
||||
if (currentEvent === "progress") {
|
||||
handlers.onProgress(parsed);
|
||||
} else if (currentEvent === "result") {
|
||||
handlers.onResult(parsed);
|
||||
} else if (currentEvent === "error") {
|
||||
handlers.onError(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSON frames
|
||||
}
|
||||
}
|
||||
currentEvent = "";
|
||||
boundary = buffer.indexOf("\n\n");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatContextValue {
|
||||
messages: ChatMessage[];
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
progress: AiWorkflowProgress | null;
|
||||
toggleOpen: () => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
/** Abort any in-flight request and reset the chat to an empty conversation. */
|
||||
clearChat: () => void;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextValue | null>(null);
|
||||
|
||||
const initialState: ChatState = {
|
||||
messages: [],
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
progress: null,
|
||||
};
|
||||
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [state, dispatch] = useReducer(chatReducer, initialState);
|
||||
const { files: activeFiles, fileStubs: activeFileStubs } = useAllFiles();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const messagesRef = useRef<ChatMessage[]>(state.messages);
|
||||
messagesRef.current = state.messages;
|
||||
|
||||
// Download a File from the Stirling files endpoint.
|
||||
const downloadFile = useCallback(
|
||||
async (descriptor: AiWorkflowResultFile): Promise<File> => {
|
||||
const response = await apiClient.get<Blob>(
|
||||
`/api/v1/general/files/${descriptor.fileId}`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return new File([response.data], descriptor.fileName, {
|
||||
type: descriptor.contentType ?? response.data.type,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Import the files produced by an AI workflow result into FileContext.
|
||||
//
|
||||
// If the workflow produced the same number of outputs as inputs, map each output to its
|
||||
// corresponding input as a new version in the same chain. Otherwise (merge, split, etc.)
|
||||
// add the outputs as new root files.
|
||||
const importResultFile = useCallback(
|
||||
async (
|
||||
result: AiWorkflowResponse,
|
||||
sourceStubs: StirlingFileStub[],
|
||||
): Promise<void> => {
|
||||
const descriptors = result.resultFiles?.length
|
||||
? result.resultFiles
|
||||
: result.fileId && result.fileName && result.contentType
|
||||
? [
|
||||
{
|
||||
fileId: result.fileId,
|
||||
fileName: result.fileName,
|
||||
contentType: result.contentType,
|
||||
} satisfies AiWorkflowResultFile,
|
||||
]
|
||||
: [];
|
||||
if (descriptors.length === 0) return;
|
||||
|
||||
const files = await Promise.all(descriptors.map(downloadFile));
|
||||
|
||||
const operation: ToolOperation = {
|
||||
toolId: "ai-workflow",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const isVersionMapping =
|
||||
sourceStubs.length > 0 && files.length === sourceStubs.length;
|
||||
const stubs = files.map((file, i) =>
|
||||
isVersionMapping
|
||||
? createChildStub(sourceStubs[i], operation, file)
|
||||
: createNewStirlingFileStub(file),
|
||||
);
|
||||
const stirlingFiles = files.map((file, i) =>
|
||||
createStirlingFile(file, stubs[i].id),
|
||||
);
|
||||
|
||||
if (sourceStubs.length > 0) {
|
||||
// Always consume the inputs so merge/split inputs are removed from the workbench.
|
||||
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
|
||||
// merge/split they're fresh roots.
|
||||
await fileActions.consumeFiles(
|
||||
sourceStubs.map((s) => s.id),
|
||||
stirlingFiles,
|
||||
stubs,
|
||||
);
|
||||
} else {
|
||||
// No inputs were provided (unlikely for completed workflows, but handle it safely).
|
||||
await fileActions.addFiles(files, { selectFiles: true });
|
||||
}
|
||||
},
|
||||
[fileActions, downloadFile],
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []);
|
||||
const setOpen = useCallback(
|
||||
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
|
||||
[],
|
||||
);
|
||||
const clearChat = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
dispatch({ type: "CLEAR" });
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
// Abort any in-flight request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const priorMessages = messagesRef.current;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: "ADD_MESSAGE", message: userMessage });
|
||||
dispatch({ type: "SET_LOADING", loading: true });
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("userMessage", content);
|
||||
activeFiles.forEach((file, i) => {
|
||||
formData.append(`fileInputs[${i}].fileInput`, file);
|
||||
});
|
||||
priorMessages.forEach((message, i) => {
|
||||
formData.append(`conversationHistory[${i}].role`, message.role);
|
||||
formData.append(`conversationHistory[${i}].content`, message.content);
|
||||
});
|
||||
|
||||
const response = await fetch("/api/v1/ai/orchestrate/stream", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
headers: getAuthHeaders(),
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail: string | undefined;
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail =
|
||||
body?.message ??
|
||||
body?.detail ??
|
||||
body?.error ??
|
||||
(Array.isArray(body?.errors)
|
||||
? body.errors[0]?.message
|
||||
: undefined);
|
||||
} catch {
|
||||
// non-JSON body — ignore
|
||||
}
|
||||
throw new Error(
|
||||
detail ?? `AI engine request failed: ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
let receivedResult = false;
|
||||
const toolsUsed: string[] = [];
|
||||
|
||||
await consumeSSEStream(response, {
|
||||
onProgress: (data) => {
|
||||
if (
|
||||
data.phase === AiWorkflowPhase.EXECUTING_TOOL &&
|
||||
typeof data.tool === "string"
|
||||
) {
|
||||
toolsUsed.push(data.tool);
|
||||
}
|
||||
dispatch({
|
||||
type: "SET_PROGRESS",
|
||||
progress: {
|
||||
phase: data.phase as AiWorkflowPhase,
|
||||
tool: data.tool,
|
||||
stepIndex: data.stepIndex,
|
||||
stepCount: data.stepCount,
|
||||
engineDetail: data.engineDetail,
|
||||
},
|
||||
});
|
||||
},
|
||||
onResult: (data) => {
|
||||
receivedResult = true;
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
const replyContent = formatWorkflowResponse(data, t);
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
content: replyContent,
|
||||
timestamp: Date.now(),
|
||||
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
|
||||
},
|
||||
});
|
||||
if (data.fileId || data.resultFiles?.length) {
|
||||
importResultFile(data, activeFileStubs).catch((err) => {
|
||||
console.error("Failed to import AI result file", err);
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
content:
|
||||
"The file was processed but I couldn't download it.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (data) => {
|
||||
receivedResult = true;
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
content: data.message || "Something went wrong.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!receivedResult) {
|
||||
throw new Error("Stream ended without a result");
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") return;
|
||||
const err = e as Error;
|
||||
const isEngineError =
|
||||
err.message.startsWith("AI engine request failed:") ||
|
||||
err.message === "Stream ended without a result";
|
||||
const content = isEngineError
|
||||
? "Failed to get a response. The AI engine may not be available yet."
|
||||
: (err.message ??
|
||||
"Failed to get a response. The AI engine may not be available yet.");
|
||||
dispatch({ type: "SET_PROGRESS", progress: null });
|
||||
dispatch({
|
||||
type: "ADD_MESSAGE",
|
||||
message: {
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: "SET_LOADING", loading: false });
|
||||
if (abortRef.current === controller) {
|
||||
abortRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeFiles, activeFileStubs, importResultFile],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
messages: state.messages,
|
||||
isOpen: state.isOpen,
|
||||
isLoading: state.isLoading,
|
||||
progress: state.progress,
|
||||
toggleOpen,
|
||||
setOpen,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChat(): ChatContextValue {
|
||||
const context = useContext(ChatContext);
|
||||
if (!context) {
|
||||
throw new Error("useChat must be used within a ChatProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Match the right-rail toolbar background so dark-mode doesn't show a
|
||||
lighter card behind the chat, and so the agent-card → pill morph
|
||||
doesn't flash a different colour mid-transition. */
|
||||
background: var(--bg-toolbar);
|
||||
}
|
||||
|
||||
.chat-panel--embedded {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Top header: agent pill + close. */
|
||||
.chat-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1rem 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-panel__agent-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.75rem 0.4rem 0.4rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-body);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 120ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
/* Pair with .agent-button to morph between the card and the pill. */
|
||||
view-transition-name: stirling-agent;
|
||||
}
|
||||
|
||||
/* Match the active-tool morph timing for a consistent rail feel. */
|
||||
::view-transition-group(stirling-agent),
|
||||
::view-transition-old(stirling-agent),
|
||||
::view-transition-new(stirling-agent) {
|
||||
animation-duration: 220ms;
|
||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
|
||||
.chat-panel__agent-pill:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.chat-panel__agent-pill-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dark-mode: let the pill blend into the rail. Just a thin border, no fill —
|
||||
so it doesn't read as a clashing lighter card on the dark toolbar. */
|
||||
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill {
|
||||
background: transparent;
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill-icon {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--mantine-color-blue-filled) 18%,
|
||||
transparent
|
||||
);
|
||||
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
|
||||
}
|
||||
|
||||
/* Same treatment for the input pill and quick-action cards. */
|
||||
[data-mantine-color-scheme="dark"] .chat-panel-input {
|
||||
background: transparent;
|
||||
box-shadow:
|
||||
0 0 0 1px var(--border-subtle),
|
||||
0 6px 16px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .chat-panel-input:focus-within {
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-3) 40%, transparent),
|
||||
0 8px 22px color-mix(in srgb, var(--mantine-color-blue-6) 18%, transparent);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .chat-quick-action {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] .chat-quick-action:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.chat-panel__agent-pill-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-panel-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Quick action cards above the input. */
|
||||
.chat-panel__quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.75rem 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-panel__quick-actions-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
/* File pills shown above the quick actions when files are loaded. */
|
||||
.chat-file-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-file-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
max-width: 11rem;
|
||||
padding: 0.2rem 0.35rem 0.2rem 0.45rem;
|
||||
border: 1px solid var(--mantine-color-blue-light);
|
||||
border-radius: 9999px;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.chat-file-pill__icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-file-pill__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-file-pill__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition:
|
||||
background 120ms ease-out,
|
||||
opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.chat-file-pill__remove:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-file-pill--more {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-subtle);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.55rem;
|
||||
}
|
||||
|
||||
.chat-file-pill--more:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.chat-quick-action {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--mantine-color-body);
|
||||
transition:
|
||||
background 120ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
.chat-quick-action:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
border-color: var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.chat-quick-action__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--mantine-color-blue-light);
|
||||
color: var(--mantine-color-blue-filled);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Input area: textarea on top row, action icons on bottom row. */
|
||||
.chat-panel-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
margin: 0.75rem 0.75rem 1rem;
|
||||
border: none;
|
||||
border-radius: 1.1rem;
|
||||
background: var(--mantine-color-body);
|
||||
flex-shrink: 0;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04),
|
||||
0 4px 14px rgba(15, 23, 42, 0.08);
|
||||
transition: box-shadow 160ms ease-out;
|
||||
}
|
||||
|
||||
.chat-panel-input:focus-within {
|
||||
box-shadow:
|
||||
0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-6) 20%, transparent),
|
||||
0 6px 18px color-mix(in srgb, var(--mantine-color-blue-6) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Kill the Mantine Textarea's own border/outline — the wrapper owns the chrome. */
|
||||
.chat-panel-input__field,
|
||||
.chat-panel-input__field:focus,
|
||||
.chat-panel-input__field:focus-visible {
|
||||
font-size: 0.95rem;
|
||||
padding: 0.15rem 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.chat-panel-input__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Message layout */
|
||||
.chat-message {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-message-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.chat-message-assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Bubble styling */
|
||||
.chat-bubble {
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.chat-bubble-user {
|
||||
background: var(--mantine-color-blue-filled) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.chat-bubble-user * {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.chat-bubble-assistant {
|
||||
background: var(--mantine-color-default-hover) !important;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Collapse,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
|
||||
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import {
|
||||
useChat,
|
||||
AiWorkflowPhase,
|
||||
isKnownEngineProgressDetail,
|
||||
type AiWorkflowProgress,
|
||||
type AnyEngineProgressDetail,
|
||||
} from "@app/components/chat/ChatContext";
|
||||
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
|
||||
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
|
||||
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
|
||||
import "@app/components/chat/ChatPanel.css";
|
||||
|
||||
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
/** Resolver mapping a tool endpoint path to its translated display name. */
|
||||
type ToolNameResolver = (endpoint: string) => string | null;
|
||||
|
||||
/**
|
||||
* Look up a tool's translated name from the tool catalog. The catalog's {@code operationConfig}
|
||||
* exposes the full API endpoint path for each tool, so we key the lookup on the exact path that
|
||||
* arrives in SSE progress events — no string parsing.
|
||||
*/
|
||||
function useToolNameResolver(): ToolNameResolver {
|
||||
const { allTools } = useTranslatedToolCatalog();
|
||||
return useMemo(() => {
|
||||
const nameByEndpoint = new Map<string, string>();
|
||||
Object.values(allTools).forEach((tool) => {
|
||||
const endpoint = tool.operationConfig?.endpoint;
|
||||
if (typeof endpoint === "string") {
|
||||
nameByEndpoint.set(endpoint, tool.name);
|
||||
}
|
||||
});
|
||||
return (endpoint: string) => nameByEndpoint.get(endpoint) ?? null;
|
||||
}, [allTools]);
|
||||
}
|
||||
|
||||
function formatProgress(
|
||||
progress: AiWorkflowProgress,
|
||||
t: TranslateFn,
|
||||
resolveToolName: ToolNameResolver,
|
||||
): string {
|
||||
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL && progress.tool) {
|
||||
const tool = resolveToolName(progress.tool);
|
||||
const hasSteps =
|
||||
progress.stepIndex != null &&
|
||||
progress.stepCount != null &&
|
||||
progress.stepCount > 1;
|
||||
if (tool) {
|
||||
return hasSteps
|
||||
? t("chat.progress.executing_tool_step", {
|
||||
tool,
|
||||
step: progress.stepIndex,
|
||||
total: progress.stepCount,
|
||||
})
|
||||
: t("chat.progress.executing_tool_single", { tool });
|
||||
}
|
||||
return hasSteps
|
||||
? t("chat.progress.executing_tool_generic_step", {
|
||||
step: progress.stepIndex,
|
||||
total: progress.stepCount,
|
||||
})
|
||||
: t("chat.progress.executing_tool_generic");
|
||||
}
|
||||
if (progress.phase === AiWorkflowPhase.ENGINE_PROGRESS) {
|
||||
return formatEngineProgress(progress.engineDetail, t);
|
||||
}
|
||||
return t(`chat.progress.${progress.phase}`);
|
||||
}
|
||||
|
||||
function formatEngineProgress(
|
||||
detail: AnyEngineProgressDetail | undefined,
|
||||
t: TranslateFn,
|
||||
): string {
|
||||
if (!detail || !isKnownEngineProgressDetail(detail)) {
|
||||
return t("chat.progress.processing");
|
||||
}
|
||||
switch (detail.phase) {
|
||||
case "whole_doc_read_started":
|
||||
return t("chat.progress.whole_doc_read_started");
|
||||
case "whole_doc_slice_done": {
|
||||
const percent =
|
||||
detail.total > 0
|
||||
? Math.round((detail.completed / detail.total) * 100)
|
||||
: 0;
|
||||
return t("chat.progress.whole_doc_slice_done", { percent });
|
||||
}
|
||||
case "whole_doc_compression_round":
|
||||
return t("chat.progress.whole_doc_compression_round");
|
||||
case "whole_doc_read_done":
|
||||
return t("chat.progress.whole_doc_read_done");
|
||||
}
|
||||
}
|
||||
|
||||
function ToolsUsedBlock({
|
||||
tools,
|
||||
resolveToolName,
|
||||
t,
|
||||
}: {
|
||||
tools: string[];
|
||||
resolveToolName: ToolNameResolver;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const names = tools.map(
|
||||
(endpoint) => resolveToolName(endpoint) ?? t("chat.toolsUsed.unknownTool"),
|
||||
);
|
||||
const label = t("chat.toolsUsed.summary", { count: tools.length });
|
||||
return (
|
||||
<Box mt={6}>
|
||||
<UnstyledButton
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{expanded ? (
|
||||
<ExpandLessIcon sx={{ fontSize: 14 }} />
|
||||
) : (
|
||||
<ExpandMoreIcon sx={{ fontSize: 14 }} />
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse in={expanded}>
|
||||
<List
|
||||
type="ordered"
|
||||
size="xs"
|
||||
mt={4}
|
||||
pl="lg"
|
||||
styles={{ itemWrapper: { lineHeight: 1.4 } }}
|
||||
>
|
||||
{names.map((name, i) => (
|
||||
<List.Item key={i}>{name}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessageBubble({
|
||||
role,
|
||||
content,
|
||||
toolsUsed,
|
||||
resolveToolName,
|
||||
t,
|
||||
}: {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
toolsUsed?: string[];
|
||||
resolveToolName: ToolNameResolver;
|
||||
t: TranslateFn;
|
||||
}) {
|
||||
return (
|
||||
<div className={`chat-message chat-message-${role}`}>
|
||||
<Paper className={`chat-bubble chat-bubble-${role}`} p="xs" radius="md">
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{content}
|
||||
</Text>
|
||||
{toolsUsed && toolsUsed.length > 0 && (
|
||||
<ToolsUsedBlock
|
||||
tools={toolsUsed}
|
||||
resolveToolName={resolveToolName}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChatPanelProps {
|
||||
/** Called when the user closes the chat to return to the tool list. */
|
||||
onBack: () => void;
|
||||
/** Accessible label for the close button. */
|
||||
backLabel: string;
|
||||
}
|
||||
|
||||
export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { messages, isLoading, progress, sendMessage, clearChat } = useChat();
|
||||
const resolveToolName = useToolNameResolver();
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleSend = (override?: string) => {
|
||||
const text = (override ?? input).trim();
|
||||
if (!text || isLoading) return;
|
||||
setInput("");
|
||||
sendMessage(text);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const showQuickActions = messages.length === 0 && !isLoading;
|
||||
|
||||
return (
|
||||
<Box className="chat-panel chat-panel--embedded">
|
||||
<div className="chat-panel__header">
|
||||
<Menu shadow="md" width={220} position="bottom-start" withinPortal>
|
||||
<Menu.Target>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-panel__agent-pill"
|
||||
aria-label={t("chat.header.agentMenu", "Stirling agent options")}
|
||||
>
|
||||
<span className="chat-panel__agent-pill-icon">
|
||||
<StirlingLogoOutline size={16} />
|
||||
</span>
|
||||
<span className="chat-panel__agent-pill-label">
|
||||
{t("agents.stirling_name", "Stirling")}
|
||||
</span>
|
||||
<KeyboardArrowDownIcon
|
||||
sx={{ fontSize: 18, color: "var(--text-muted)" }}
|
||||
/>
|
||||
</button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<DeleteSweepIcon sx={{ fontSize: 18 }} />}
|
||||
onClick={clearChat}
|
||||
disabled={messages.length === 0 && !isLoading}
|
||||
>
|
||||
{t("chat.header.clearChat", "Clear chat")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={onBack}
|
||||
aria-label={backLabel}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="chat-panel-messages" viewportRef={scrollRef}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessageBubble
|
||||
key={msg.id}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
toolsUsed={msg.toolsUsed}
|
||||
resolveToolName={resolveToolName}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="chat-message chat-message-assistant">
|
||||
<Paper
|
||||
className="chat-bubble chat-bubble-assistant"
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Loader size="xs" type="dots" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{progress
|
||||
? formatProgress(progress, t, resolveToolName)
|
||||
: t("chat.progress.thinking")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
{showQuickActions && (
|
||||
<ChatQuickActions
|
||||
heading={t("chat.quickActions.heading", "Get started")}
|
||||
onAction={(text) => handleSend(text)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="chat-panel-input">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
placeholder={t("chat.input.placeholder", "What do you want to do?")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
variant="unstyled"
|
||||
classNames={{ input: "chat-panel-input__field" }}
|
||||
/>
|
||||
<div className="chat-panel-input__actions">
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="md"
|
||||
onClick={() => handleSend()}
|
||||
disabled={!input.trim() || isLoading}
|
||||
aria-label={t("chat.input.send", "Send message")}
|
||||
>
|
||||
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import CompressIcon from "@mui/icons-material/Compress";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFileOutlined";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import LayersIcon from "@mui/icons-material/Layers";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { detectFileExtension, isPdfFile } from "@app/utils/fileUtils";
|
||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
|
||||
const MAX_FILE_PILLS = 3;
|
||||
|
||||
interface QuickAction {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function QuickActionCard({ action }: { action: QuickAction }) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
className="chat-quick-action"
|
||||
onClick={action.onClick}
|
||||
aria-label={action.title}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
<Box className="chat-quick-action__icon">{action.icon}</Box>
|
||||
<Box style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{action.title}
|
||||
</Text>
|
||||
{action.subtitle && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{action.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<KeyboardArrowDownIcon
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
transform: "rotate(-90deg)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkbenchFilePills({
|
||||
stubs,
|
||||
onOpenFilesModal,
|
||||
onRemove,
|
||||
moreLabel,
|
||||
removeLabel,
|
||||
}: {
|
||||
stubs: StirlingFileStub[];
|
||||
onOpenFilesModal: () => void;
|
||||
onRemove: (id: StirlingFileStub["id"]) => void;
|
||||
moreLabel: (count: number) => string;
|
||||
removeLabel: (name: string) => string;
|
||||
}) {
|
||||
const visible = stubs.slice(0, MAX_FILE_PILLS);
|
||||
const overflow = Math.max(0, stubs.length - visible.length);
|
||||
return (
|
||||
<div className="chat-file-pills">
|
||||
{visible.map((stub) => (
|
||||
<span key={stub.id} className="chat-file-pill">
|
||||
<InsertDriveFileIcon
|
||||
className="chat-file-pill__icon"
|
||||
sx={{ fontSize: 14 }}
|
||||
/>
|
||||
<span className="chat-file-pill__label" title={stub.name}>
|
||||
{stub.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-file-pill__remove"
|
||||
onClick={() => onRemove(stub.id)}
|
||||
aria-label={removeLabel(stub.name)}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 12 }} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<UnstyledButton
|
||||
className="chat-file-pill chat-file-pill--more"
|
||||
onClick={onOpenFilesModal}
|
||||
>
|
||||
{moreLabel(overflow)}
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkbenchSummary {
|
||||
fileCount: number;
|
||||
pdfCount: number;
|
||||
nonPdfCount: number;
|
||||
hasNonPdf: boolean;
|
||||
singleFilePageCount: number | null;
|
||||
typeBreakdown: { label: string; count: number }[];
|
||||
}
|
||||
|
||||
function summariseWorkbench(stubs: StirlingFileStub[]): WorkbenchSummary {
|
||||
const counts = new Map<string, number>();
|
||||
let pdfCount = 0;
|
||||
let nonPdfCount = 0;
|
||||
|
||||
for (const stub of stubs) {
|
||||
const ext = detectFileExtension(stub.name ?? "");
|
||||
const isPdf = isPdfFile({ name: stub.name, type: stub.type });
|
||||
if (isPdf) pdfCount += 1;
|
||||
else nonPdfCount += 1;
|
||||
const label = ext ? ext.toUpperCase() : "FILE";
|
||||
counts.set(label, (counts.get(label) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const typeBreakdown = Array.from(counts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([label, count]) => ({ label, count }));
|
||||
|
||||
return {
|
||||
fileCount: stubs.length,
|
||||
pdfCount,
|
||||
nonPdfCount,
|
||||
hasNonPdf: nonPdfCount > 0,
|
||||
singleFilePageCount:
|
||||
stubs.length === 1 ? (stubs[0].processedFile?.totalPages ?? null) : null,
|
||||
typeBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatQuickActionsProps {
|
||||
/** Heading text shown above the actions. */
|
||||
heading: string;
|
||||
/** Invoked when the user selects an action — sends the given text as a chat message. */
|
||||
onAction: (text: string) => void;
|
||||
}
|
||||
|
||||
export function ChatQuickActions({ heading, onAction }: ChatQuickActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { fileStubs } = useAllFiles();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
const summary = useMemo(() => summariseWorkbench(fileStubs), [fileStubs]);
|
||||
|
||||
const actions = useMemo<QuickAction[]>(() => {
|
||||
const send = (text: string) => () => onAction(text);
|
||||
|
||||
if (summary.fileCount === 0) {
|
||||
return [
|
||||
{
|
||||
key: "open-from-computer",
|
||||
icon: <UploadFileIcon sx={{ fontSize: 18 }} />,
|
||||
title: t("chat.quickActions.openFromComputer", "Open from computer"),
|
||||
subtitle: t("chat.quickActions.browseYourFiles", "Browse your files"),
|
||||
onClick: () => openFilesModal(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (summary.fileCount === 1) {
|
||||
// Non-PDF: only suggest converting to PDF.
|
||||
if (summary.hasNonPdf) {
|
||||
const text = t(
|
||||
"chat.quickActions.convertOne",
|
||||
"Convert this document to PDF",
|
||||
);
|
||||
return [
|
||||
{
|
||||
key: "convert",
|
||||
icon: <PictureAsPdfIcon sx={{ fontSize: 18 }} />,
|
||||
title: text,
|
||||
onClick: send(text),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const result: QuickAction[] = [];
|
||||
const hasMultiplePages =
|
||||
summary.singleFilePageCount != null && summary.singleFilePageCount > 1;
|
||||
if (hasMultiplePages) {
|
||||
const text = t("chat.quickActions.splitOne", "Split this document");
|
||||
result.push({
|
||||
key: "split",
|
||||
icon: <ContentCutIcon sx={{ fontSize: 18 }} />,
|
||||
title: text,
|
||||
onClick: send(text),
|
||||
});
|
||||
}
|
||||
const compressText = t(
|
||||
"chat.quickActions.compressOne",
|
||||
"Compress this document",
|
||||
);
|
||||
const rotateText = t(
|
||||
"chat.quickActions.rotateOne",
|
||||
"Rotate this document",
|
||||
);
|
||||
result.push({
|
||||
key: "compress",
|
||||
icon: <CompressIcon sx={{ fontSize: 18 }} />,
|
||||
title: compressText,
|
||||
onClick: send(compressText),
|
||||
});
|
||||
result.push({
|
||||
key: "rotate",
|
||||
icon: <RotateRightIcon sx={{ fontSize: 18 }} />,
|
||||
title: rotateText,
|
||||
onClick: send(rotateText),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// Multiple files.
|
||||
const result: QuickAction[] = [];
|
||||
if (summary.hasNonPdf) {
|
||||
const text = t(
|
||||
"chat.quickActions.convertMany",
|
||||
"Convert these documents to PDF",
|
||||
);
|
||||
result.push({
|
||||
key: "convert",
|
||||
icon: <PictureAsPdfIcon sx={{ fontSize: 18 }} />,
|
||||
title: text,
|
||||
onClick: send(text),
|
||||
});
|
||||
}
|
||||
const mergeText = t("chat.quickActions.mergeMany", {
|
||||
count: summary.fileCount,
|
||||
defaultValue: "Merge these {{count}} documents into 1",
|
||||
});
|
||||
const compressText = t(
|
||||
"chat.quickActions.compressMany",
|
||||
"Compress these documents",
|
||||
);
|
||||
const rotateText = t(
|
||||
"chat.quickActions.rotateMany",
|
||||
"Rotate these documents",
|
||||
);
|
||||
result.push({
|
||||
key: "merge",
|
||||
icon: <LayersIcon sx={{ fontSize: 18 }} />,
|
||||
title: mergeText,
|
||||
onClick: send(mergeText),
|
||||
});
|
||||
result.push({
|
||||
key: "compress",
|
||||
icon: <CompressIcon sx={{ fontSize: 18 }} />,
|
||||
title: compressText,
|
||||
onClick: send(compressText),
|
||||
});
|
||||
result.push({
|
||||
key: "rotate",
|
||||
icon: <RotateRightIcon sx={{ fontSize: 18 }} />,
|
||||
title: rotateText,
|
||||
onClick: send(rotateText),
|
||||
});
|
||||
return result;
|
||||
}, [summary, t, onAction, openFilesModal]);
|
||||
|
||||
return (
|
||||
<div className="chat-panel__quick-actions">
|
||||
{summary.fileCount > 0 && (
|
||||
<WorkbenchFilePills
|
||||
stubs={fileStubs}
|
||||
onOpenFilesModal={() => openFilesModal()}
|
||||
onRemove={(id) => fileActions.removeFiles([id])}
|
||||
moreLabel={(count) =>
|
||||
t("chat.quickActions.moreFiles", {
|
||||
count,
|
||||
defaultValue: "+{{count}} more",
|
||||
})
|
||||
}
|
||||
removeLabel={(name) =>
|
||||
t("chat.quickActions.removeFile", {
|
||||
name,
|
||||
defaultValue: "Remove {{name}}",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Text className="chat-panel__quick-actions-label">{heading}</Text>
|
||||
<Stack gap="xs">
|
||||
{actions.map((action) => (
|
||||
<QuickActionCard key={action.key} action={action} />
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
|
||||
/**
|
||||
* Hook that provides the proprietary tool registry.
|
||||
*
|
||||
* This is the definition of all proprietary tools,
|
||||
* and will be included in the main tool registry.
|
||||
*/
|
||||
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
|
||||
return useMemo<ProprietaryToolRegistry>(() => ({}), []);
|
||||
}
|
||||
@@ -6,7 +6,11 @@
|
||||
|
||||
export const PROPRIETARY_REGULAR_TOOL_IDS = [] as const;
|
||||
|
||||
export const PROPRIETARY_SUPER_TOOL_IDS = [] as const;
|
||||
// "ai-workflow" is a generic marker stamped onto files produced by the agents
|
||||
// chat orchestrator (which may invoke one or more underlying tools). Lives here
|
||||
// as a super-tool so ``ToolOperation.toolId`` stays typed; not user-pickable —
|
||||
// see ChatContext.tsx. The tool registry doesn't include it as an entry.
|
||||
export const PROPRIETARY_SUPER_TOOL_IDS = ["ai-workflow"] as const;
|
||||
|
||||
export const PROPRIETARY_LINK_TOOL_IDS = [] as const;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user