mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
ui(v2 managed agents): polish sessions UI — left rail, refined typography, dev-tool aesthetic
- sessions list as left rail (was full-page list view) - group sessions by Today/This Week/Last Week/Older - active row uses subtle bg (was 2px border) - status dots only render for non-ready states - header shows agent name from agentNameById fallback - composer in rounded bordered container with consolidated footer - stop button is filled-dark SVG square (was bold ascii character) - markdown sizes tightened (h1 17px, h2 15px, h3 13.5px, body 13.5/1.6) - user messages render as bold quote-headers with hairline separator - redirect /sessions to latest session
This commit is contained in:
parent
3e86ce0680
commit
2ed9408ed3
4 changed files with 1201 additions and 0 deletions
8
ui/litellm-dashboard/src/app/sessions/[sid]/page.tsx
Normal file
8
ui/litellm-dashboard/src/app/sessions/[sid]/page.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import SessionThreadView from "./view";
|
||||
|
||||
export default function SessionThreadPage() {
|
||||
return <SessionThreadView />;
|
||||
}
|
||||
782
ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx
Normal file
782
ui/litellm-dashboard/src/app/sessions/[sid]/view.tsx
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
type MessageStatus = "in_progress" | "completed" | "failed";
|
||||
type MessageRole = "user" | "assistant";
|
||||
|
||||
interface ToolCall {
|
||||
name: string;
|
||||
input?: unknown;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
status: MessageStatus;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
tools?: ToolCall[];
|
||||
model?: string;
|
||||
error_reason?: string;
|
||||
}
|
||||
|
||||
interface SandboxSpec {
|
||||
type: string;
|
||||
size: string;
|
||||
timeout_minutes?: number;
|
||||
idle_timeout_minutes?: number;
|
||||
}
|
||||
|
||||
interface RepoSpec {
|
||||
url: string;
|
||||
starting_ref: string;
|
||||
checked_out_sha?: string;
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
agent_name?: string;
|
||||
sandbox: SandboxSpec;
|
||||
status: string;
|
||||
repos: RepoSpec[];
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
terminated_at: string | null;
|
||||
default_model?: string;
|
||||
}
|
||||
|
||||
interface ListResponse<T> {
|
||||
data: T[];
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PROXY = "http://localhost:4000";
|
||||
const DEFAULT_KEY = "sk-1234";
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const COLUMN_MAX_WIDTH = 720;
|
||||
|
||||
const getProxyBase = (): string =>
|
||||
(typeof window !== "undefined" &&
|
||||
localStorage.getItem("LITELLM_PROXY_URL")) ||
|
||||
DEFAULT_PROXY;
|
||||
|
||||
const getApiKey = (): string =>
|
||||
(typeof window !== "undefined" && localStorage.getItem("LITELLM_API_KEY")) ||
|
||||
DEFAULT_KEY;
|
||||
|
||||
const buildHeaders = (): HeadersInit => ({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getApiKey()}`,
|
||||
});
|
||||
|
||||
const truncate = (s: string, n: number): string =>
|
||||
s.length > n ? s.slice(0, n) + "…" : s;
|
||||
|
||||
const formatRelative = (iso: string | null | undefined): string => {
|
||||
if (!iso) return "";
|
||||
try {
|
||||
const d = new Date(iso).getTime();
|
||||
const now = Date.now();
|
||||
const diff = Math.max(0, now - d);
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 7) return `${day}d`;
|
||||
const wk = Math.floor(day / 7);
|
||||
if (wk < 5) return `${wk}w`;
|
||||
const mo = Math.floor(day / 30);
|
||||
if (mo < 12) return `${mo}mo`;
|
||||
return `${Math.floor(day / 365)}y`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = (status: string): string => {
|
||||
switch ((status || "").toLowerCase()) {
|
||||
case "error":
|
||||
case "failed":
|
||||
return "#dc2626";
|
||||
case "provisioning":
|
||||
case "pending":
|
||||
case "queued":
|
||||
return "#d97706";
|
||||
default:
|
||||
// ready, terminated, anything else — subtle gray
|
||||
return "#bcbcc0";
|
||||
}
|
||||
};
|
||||
|
||||
const groupSessionsByAge = (
|
||||
sessions: SessionRow[],
|
||||
): Array<{ label: string; items: SessionRow[] }> => {
|
||||
const now = Date.now();
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const buckets: Record<string, SessionRow[]> = {
|
||||
Today: [],
|
||||
"This Week": [],
|
||||
"Last Week": [],
|
||||
Older: [],
|
||||
};
|
||||
for (const s of sessions) {
|
||||
const age = now - new Date(s.created_at).getTime();
|
||||
if (age < day) buckets.Today.push(s);
|
||||
else if (age < 7 * day) buckets["This Week"].push(s);
|
||||
else if (age < 14 * day) buckets["Last Week"].push(s);
|
||||
else buckets.Older.push(s);
|
||||
}
|
||||
return [
|
||||
{ label: "Today", items: buckets.Today },
|
||||
{ label: "This Week", items: buckets["This Week"] },
|
||||
{ label: "Last Week", items: buckets["Last Week"] },
|
||||
{ label: "Older", items: buckets.Older },
|
||||
].filter((g) => g.items.length > 0);
|
||||
};
|
||||
|
||||
export default function SessionThreadView() {
|
||||
const params = useParams<{ sid: string }>();
|
||||
const router = useRouter();
|
||||
const sessionId = params?.sid || "";
|
||||
|
||||
const [session, setSession] = useState<SessionRow | null>(null);
|
||||
const [messages, setMessages] = useState<MessageRow[]>([]);
|
||||
const [draft, setDraft] = useState<string>("");
|
||||
const [sending, setSending] = useState<boolean>(false);
|
||||
const [aborting, setAborting] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionsList, setSessionsList] = useState<SessionRow[]>([]);
|
||||
const [agentNameById, setAgentNameById] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Fetch sessions + agents (for the left rail)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const proxy = getProxyBase();
|
||||
const headers = buildHeaders();
|
||||
try {
|
||||
const [sRes, aRes] = await Promise.all([
|
||||
fetch(`${proxy}/v2/sessions?limit=100`, { headers }),
|
||||
fetch(`${proxy}/v2/agents?limit=100`, { headers }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
if (sRes.ok) {
|
||||
const data: ListResponse<SessionRow> = await sRes.json();
|
||||
setSessionsList(data.data || []);
|
||||
}
|
||||
if (aRes.ok) {
|
||||
const data: ListResponse<{ id: string; name: string }> =
|
||||
await aRes.json();
|
||||
const map: Record<string, string> = {};
|
||||
for (const a of data.data || []) map[a.id] = a.name;
|
||||
setAgentNameById(map);
|
||||
}
|
||||
} catch {
|
||||
// silent — rail is non-critical
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId]); // refresh rail when nav changes
|
||||
|
||||
const hasInProgress = useMemo(
|
||||
() => messages.some((m) => m.status === "in_progress"),
|
||||
[messages],
|
||||
);
|
||||
|
||||
const currentModel = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m.role === "assistant" && m.model) return m.model;
|
||||
}
|
||||
return session?.default_model || "";
|
||||
}, [messages, session]);
|
||||
|
||||
const loadSession = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const proxy = getProxyBase();
|
||||
const headers = buildHeaders();
|
||||
const [sessionRes, messagesRes] = await Promise.all([
|
||||
fetch(`${proxy}/v2/sessions/${sessionId}`, { headers }),
|
||||
fetch(`${proxy}/v2/sessions/${sessionId}/messages`, { headers }),
|
||||
]);
|
||||
|
||||
if (sessionRes.ok) {
|
||||
setSession(await sessionRes.json());
|
||||
} else {
|
||||
throw new Error(`Failed to fetch session: ${sessionRes.status}`);
|
||||
}
|
||||
|
||||
if (messagesRes.ok) {
|
||||
const m: ListResponse<MessageRow> = await messagesRes.json();
|
||||
setMessages(m.data || []);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSession();
|
||||
}, [loadSession]);
|
||||
|
||||
// Poll while any message is in_progress
|
||||
useEffect(() => {
|
||||
if (!sessionId || !hasInProgress) return;
|
||||
let cancelled = false;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${getProxyBase()}/v2/sessions/${sessionId}/messages`,
|
||||
{ headers: buildHeaders() },
|
||||
);
|
||||
if (!res.ok || cancelled) return;
|
||||
const m: ListResponse<MessageRow> = await res.json();
|
||||
if (!cancelled) setMessages(m.data || []);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [sessionId, hasInProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const content = draft.trim();
|
||||
if (!content || !sessionId || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
|
||||
const optimisticId = `optimistic-${Date.now()}`;
|
||||
const optimistic: MessageRow = {
|
||||
id: optimisticId,
|
||||
session_id: sessionId,
|
||||
role: "user",
|
||||
content,
|
||||
status: "completed",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, optimistic]);
|
||||
setDraft("");
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${getProxyBase()}/v2/sessions/${sessionId}/messages`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: buildHeaders(),
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
throw new Error(`${res.status} ${errText || res.statusText}`);
|
||||
}
|
||||
const refreshed = await fetch(
|
||||
`${getProxyBase()}/v2/sessions/${sessionId}/messages`,
|
||||
{ headers: buildHeaders() },
|
||||
);
|
||||
if (refreshed.ok) {
|
||||
const m: ListResponse<MessageRow> = await refreshed.json();
|
||||
setMessages(m.data || []);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setMessages((prev) => prev.filter((x) => x.id !== optimisticId));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [draft, sessionId, sending]);
|
||||
|
||||
const handleAbort = useCallback(async () => {
|
||||
if (!sessionId || aborting) return;
|
||||
setAborting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${getProxyBase()}/v2/sessions/${sessionId}/abort`,
|
||||
{ method: "POST", headers: buildHeaders() },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
throw new Error(`${res.status} ${errText || res.statusText}`);
|
||||
}
|
||||
const refreshed = await fetch(
|
||||
`${getProxyBase()}/v2/sessions/${sessionId}/messages`,
|
||||
{ headers: buildHeaders() },
|
||||
);
|
||||
if (refreshed.ok) {
|
||||
const m: ListResponse<MessageRow> = await refreshed.json();
|
||||
setMessages(m.data || []);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setAborting(false);
|
||||
}
|
||||
}, [sessionId, aborting]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh" }}>
|
||||
<SessionsRail
|
||||
sessions={sessionsList}
|
||||
agentNameById={agentNameById}
|
||||
activeId={sessionId}
|
||||
onPick={(id) => router.push(`/sessions/${id}`)}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div className="sessions-header">
|
||||
<span
|
||||
style={{
|
||||
color: "var(--text-primary)",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
letterSpacing: "-0.005em",
|
||||
}}
|
||||
>
|
||||
{session?.agent_name ||
|
||||
(session ? agentNameById[session.agent_id] : "") ||
|
||||
"Session"}
|
||||
</span>
|
||||
{session?.status && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{session.status !== "ready" && (
|
||||
<span
|
||||
className="sessions-status-dot"
|
||||
style={{ background: statusColor(session.status) }}
|
||||
/>
|
||||
)}
|
||||
{session.status}
|
||||
</span>
|
||||
)}
|
||||
<div className="sessions-header-right" />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: COLUMN_MAX_WIDTH,
|
||||
margin: "0 auto",
|
||||
padding: "32px 24px 24px",
|
||||
}}
|
||||
>
|
||||
{loading && messages.length === 0 && (
|
||||
<div style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{!loading && messages.length === 0 && (
|
||||
<div style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||
No messages. Send one below.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<MessageRowView key={m.id} msg={m} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: "var(--bg-page)" }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: COLUMN_MAX_WIDTH,
|
||||
margin: "0 auto",
|
||||
padding: "10px 24px 20px",
|
||||
}}
|
||||
>
|
||||
<div className="sessions-composer-wrap">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a follow up"
|
||||
disabled={sending}
|
||||
rows={2}
|
||||
className="sessions-composer-textarea"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginTop: 8,
|
||||
minHeight: 22,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="sessions-mono"
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
color: error ? "#b91c1c" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
{error
|
||||
? error
|
||||
: sending
|
||||
? "sending…"
|
||||
: currentModel || ""}
|
||||
</span>
|
||||
{hasInProgress ? (
|
||||
<button
|
||||
className="sessions-stop-btn"
|
||||
onClick={handleAbort}
|
||||
disabled={aborting}
|
||||
aria-label="Stop"
|
||||
title="Stop"
|
||||
>
|
||||
<svg
|
||||
width="9"
|
||||
height="9"
|
||||
viewBox="0 0 9 9"
|
||||
fill="currentColor"
|
||||
>
|
||||
<rect x="0" y="0" width="9" height="9" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="sessions-mono"
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
⌘+↵
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsRail({
|
||||
sessions,
|
||||
agentNameById,
|
||||
activeId,
|
||||
onPick,
|
||||
}: {
|
||||
sessions: SessionRow[];
|
||||
agentNameById: Record<string, string>;
|
||||
activeId: string;
|
||||
onPick: (id: string) => void;
|
||||
}) {
|
||||
const groups = useMemo(() => groupSessionsByAge(sessions), [sessions]);
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
width: 230,
|
||||
flexShrink: 0,
|
||||
borderRight: "1px solid var(--border-color)",
|
||||
background: "var(--bg-rail)",
|
||||
overflowY: "auto",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
fontSize: 12.5,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary)",
|
||||
letterSpacing: "-0.005em",
|
||||
}}
|
||||
>
|
||||
Sessions
|
||||
</div>
|
||||
{sessions.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 14px",
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
No sessions yet.
|
||||
</div>
|
||||
)}
|
||||
{groups.map((g) => (
|
||||
<div key={g.label}>
|
||||
<div className="sessions-rail-section">{g.label}</div>
|
||||
{g.items.map((s) => {
|
||||
const active = s.id === activeId;
|
||||
const label = agentNameById[s.agent_id] || s.agent_id;
|
||||
const showStatusDot = s.status !== "ready";
|
||||
return (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => onPick(s.id)}
|
||||
className="sessions-rail-row"
|
||||
data-active={active}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{showStatusDot && (
|
||||
<span
|
||||
className="sessions-status-dot"
|
||||
style={{ background: statusColor(s.status), margin: 0 }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className="sessions-mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "var(--text-muted)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{formatRelative(s.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRowView({ msg }: { msg: MessageRow }) {
|
||||
if (msg.role === "user") return <UserMessage msg={msg} />;
|
||||
return <AssistantMessage msg={msg} />;
|
||||
}
|
||||
|
||||
function UserMessage({ msg }: { msg: MessageRow }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 12,
|
||||
margin: "28px 0 16px",
|
||||
paddingTop: 16,
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 13.5,
|
||||
lineHeight: 1.55,
|
||||
color: "var(--text-primary)",
|
||||
fontWeight: 500,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
letterSpacing: "-0.005em",
|
||||
}}
|
||||
>
|
||||
{msg.content}
|
||||
</div>
|
||||
<span
|
||||
className="sessions-mono"
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
color: "var(--text-muted)",
|
||||
flexShrink: 0,
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{formatRelative(msg.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssistantMessage({ msg }: { msg: MessageRow }) {
|
||||
const failed = msg.status === "failed";
|
||||
const inProgress = msg.status === "in_progress";
|
||||
|
||||
return (
|
||||
<div style={{ margin: "8px 0 28px" }}>
|
||||
{msg.content ? (
|
||||
<div
|
||||
className="sessions-md"
|
||||
style={{ color: failed ? "#b91c1c" : "var(--text-primary)" }}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{msg.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : inProgress ? (
|
||||
<div style={{ color: "var(--text-muted)", fontSize: 13.5 }}>
|
||||
thinking…
|
||||
</div>
|
||||
) : null}
|
||||
{failed && msg.error_reason && (
|
||||
<div
|
||||
className="sessions-mono"
|
||||
style={{ fontSize: 11, color: "#b91c1c", marginTop: 6 }}
|
||||
>
|
||||
{msg.error_reason}
|
||||
</div>
|
||||
)}
|
||||
{msg.tools && msg.tools.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
{msg.tools.map((t, i) => (
|
||||
<ToolCallView key={i} tool={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallView({ tool }: { tool: ToolCall }) {
|
||||
const inputStr =
|
||||
tool.input === undefined || tool.input === null
|
||||
? ""
|
||||
: typeof tool.input === "string"
|
||||
? tool.input
|
||||
: JSON.stringify(tool.input, null, 2);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 6,
|
||||
marginBottom: 10,
|
||||
background: "var(--bg-code)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="sessions-mono"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
color: "var(--text-secondary)",
|
||||
padding: "6px 10px",
|
||||
borderBottom: "1px solid var(--border-color)",
|
||||
background: "var(--bg-rail)",
|
||||
}}
|
||||
>
|
||||
tool · {tool.name}
|
||||
</div>
|
||||
{inputStr && (
|
||||
<pre
|
||||
className="sessions-mono"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "8px 10px",
|
||||
fontSize: 11.5,
|
||||
color: "var(--text-secondary)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
maxHeight: 220,
|
||||
overflow: "auto",
|
||||
borderBottom: tool.output
|
||||
? "1px solid var(--border-color)"
|
||||
: "none",
|
||||
background: "var(--bg-code)",
|
||||
}}
|
||||
>
|
||||
{inputStr}
|
||||
</pre>
|
||||
)}
|
||||
{tool.output && (
|
||||
<pre
|
||||
className="sessions-mono"
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: "8px 10px",
|
||||
fontSize: 11.5,
|
||||
color: "var(--text-primary)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
maxHeight: 220,
|
||||
overflow: "auto",
|
||||
background: "var(--bg-code)",
|
||||
}}
|
||||
>
|
||||
{tool.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
354
ui/litellm-dashboard/src/app/sessions/layout.tsx
Normal file
354
ui/litellm-dashboard/src/app/sessions/layout.tsx
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import AntdGlobalProvider from "@/contexts/AntdGlobalProvider";
|
||||
|
||||
/**
|
||||
* Standalone layout for v2 sessions UI.
|
||||
*
|
||||
* Sessions-led parent for the managed agents experience. Same minimal
|
||||
* monochrome dev-tool surface as /agents — no admin shell.
|
||||
*/
|
||||
export default function SessionsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AntdGlobalProvider>
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "#ffffff",
|
||||
color: "#111827",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
fontSize: 13,
|
||||
letterSpacing: "-0.005em",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<style jsx global>{`
|
||||
:root {
|
||||
--bg-page: #ffffff;
|
||||
--bg-rail: #fafafa;
|
||||
--bg-code: #f6f8fa;
|
||||
--bg-hover: #f1f3f5;
|
||||
--bg-selected: #ebedf0;
|
||||
--border-color: #ececf0;
|
||||
--border-strong: #d1d5db;
|
||||
--text-primary: #1f1f1f;
|
||||
--text-secondary: #5e5e5e;
|
||||
--text-muted: #9b9ba0;
|
||||
--mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.sessions-mono {
|
||||
font-family: var(--mono-font);
|
||||
font-feature-settings: "liga" 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sessions-header {
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 14px;
|
||||
background: var(--bg-page);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sessions-header-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sessions-header-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sessions-btn {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-page);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sessions-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.sessions-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sessions-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sessions-chip:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.sessions-chip[data-active="true"] {
|
||||
background: #111827;
|
||||
color: #ffffff;
|
||||
border-color: #111827;
|
||||
}
|
||||
|
||||
.sessions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sessions-table thead th {
|
||||
text-align: left;
|
||||
font-weight: 500;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.sessions-table tbody td {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sessions-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sessions-table tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.sessions-status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sessions-link-back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sessions-link-back:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sessions-rail-section {
|
||||
padding: 14px 12px 4px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.sessions-rail-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
color: var(--text-primary);
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
margin: 0 6px;
|
||||
width: calc(100% - 12px);
|
||||
}
|
||||
.sessions-rail-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.sessions-rail-row[data-active="true"] {
|
||||
background: var(--bg-selected);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Markdown rendering — tighter, more refined */
|
||||
.sessions-md {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.sessions-md > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.sessions-md > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.sessions-md h1,
|
||||
.sessions-md h2,
|
||||
.sessions-md h3,
|
||||
.sessions-md h4 {
|
||||
margin: 20px 0 6px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sessions-md h1 {
|
||||
font-size: 17px;
|
||||
}
|
||||
.sessions-md h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
.sessions-md h3 {
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.sessions-md h4 {
|
||||
font-size: 13px;
|
||||
}
|
||||
.sessions-md p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.sessions-md ul,
|
||||
.sessions-md ol {
|
||||
margin: 0 0 10px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.sessions-md li {
|
||||
margin: 1px 0;
|
||||
}
|
||||
.sessions-md a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.sessions-md strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.sessions-md em {
|
||||
font-style: italic;
|
||||
}
|
||||
.sessions-md code {
|
||||
font-family: var(--mono-font);
|
||||
font-size: 12.5px;
|
||||
background: var(--bg-code);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.sessions-md pre {
|
||||
margin: 0 0 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-code);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
font-family: var(--mono-font);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sessions-md pre code {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
.sessions-md blockquote {
|
||||
margin: 0 0 12px;
|
||||
padding: 4px 12px;
|
||||
border-left: 2px solid var(--border-strong);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sessions-md table {
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 12px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.sessions-md th,
|
||||
.sessions-md td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.sessions-md th {
|
||||
background: var(--bg-rail);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sessions-composer-wrap {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-page);
|
||||
padding: 12px 14px 10px;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.sessions-composer-wrap:focus-within {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.sessions-composer-textarea {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.sessions-composer-textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sessions-stop-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--text-primary);
|
||||
color: #ffffff;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.sessions-stop-btn:hover {
|
||||
background: #000000;
|
||||
}
|
||||
.sessions-stop-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.sessions-stop-btn svg {
|
||||
display: block;
|
||||
}
|
||||
`}</style>
|
||||
</AntdGlobalProvider>
|
||||
);
|
||||
}
|
||||
57
ui/litellm-dashboard/src/app/sessions/page.tsx
Normal file
57
ui/litellm-dashboard/src/app/sessions/page.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const DEFAULT_PROXY = "http://localhost:4000";
|
||||
const DEFAULT_KEY = "sk-1234";
|
||||
|
||||
const getProxyBase = (): string =>
|
||||
(typeof window !== "undefined" &&
|
||||
localStorage.getItem("LITELLM_PROXY_URL")) ||
|
||||
DEFAULT_PROXY;
|
||||
|
||||
const getApiKey = (): string =>
|
||||
(typeof window !== "undefined" && localStorage.getItem("LITELLM_API_KEY")) ||
|
||||
DEFAULT_KEY;
|
||||
|
||||
export default function SessionsIndexPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${getProxyBase()}/v2/sessions?limit=1`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getApiKey()}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok || cancelled) return;
|
||||
const data: { data: { id: string }[] } = await res.json();
|
||||
const first = data.data?.[0];
|
||||
if (first && !cancelled) {
|
||||
router.replace(`/sessions/${first.id}`);
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
color: "var(--text-muted)",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Loading sessions…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue