mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
feat(dashboard): chat-first test slide-over (Option A) (#2660)
Integrated into release/v3.8.3
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { useApiKey } from "../../providers/hooks/useApiKey";
|
||||
@@ -20,10 +27,23 @@ interface Stats {
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
export interface LlmChatControls {
|
||||
clear: () => void;
|
||||
hasMessages: boolean;
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
providerId: string;
|
||||
initialModel?: string;
|
||||
embedded?: boolean;
|
||||
hideToolbar?: boolean;
|
||||
model?: string;
|
||||
onModelChange?: (model: string) => void;
|
||||
selectedKey?: string;
|
||||
onSelectedKeyChange?: (key: string) => void;
|
||||
controlsRef?: RefObject<LlmChatControls | null>;
|
||||
onControlsChange?: (controls: LlmChatControls) => void;
|
||||
}
|
||||
|
||||
function extractDeltaContent(line: string): string {
|
||||
@@ -60,22 +80,63 @@ function extractUsage(line: string): { prompt_tokens?: number; completion_tokens
|
||||
}
|
||||
}
|
||||
|
||||
export function LlmChatCard({ providerId, initialModel, embedded = false }: Props) {
|
||||
export function LlmChatCard({
|
||||
providerId,
|
||||
initialModel,
|
||||
embedded = false,
|
||||
hideToolbar = false,
|
||||
model: modelProp,
|
||||
onModelChange,
|
||||
selectedKey: selectedKeyProp,
|
||||
onSelectedKeyChange,
|
||||
controlsRef,
|
||||
onControlsChange,
|
||||
}: Props) {
|
||||
const t = useTranslations("miniPlayground");
|
||||
const { apiKey, keys } = useApiKey();
|
||||
const { models } = useProviderModels(providerId);
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<string>("");
|
||||
const [model, setModel] = useState<string>(initialModel ?? "");
|
||||
const [internalSelectedKey, setInternalSelectedKey] = useState<string>("");
|
||||
const [internalModel, setInternalModel] = useState<string>(initialModel ?? "");
|
||||
const selectedKey = selectedKeyProp ?? internalSelectedKey;
|
||||
const setSelectedKey = useCallback(
|
||||
(k: string) => {
|
||||
if (onSelectedKeyChange) onSelectedKeyChange(k);
|
||||
else setInternalSelectedKey(k);
|
||||
},
|
||||
[onSelectedKeyChange]
|
||||
);
|
||||
const model = modelProp ?? internalModel;
|
||||
const setModel = useCallback(
|
||||
(m: string) => {
|
||||
if (onModelChange) onModelChange(m);
|
||||
else setInternalModel(m);
|
||||
},
|
||||
[onModelChange]
|
||||
);
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [streaming, setStreaming] = useState<boolean>(false);
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const firstModel = models[0]?.id ?? "";
|
||||
const effectiveModel = model || firstModel || initialModel || "";
|
||||
// Auto-prefix model with providerId when no provider/model prefix present, to avoid
|
||||
// OmniRoute "Ambiguous model" rejection when same alias is registered under multiple providers.
|
||||
const qualifiedModel = effectiveModel.includes("/")
|
||||
? effectiveModel
|
||||
: providerId
|
||||
? `${providerId}/${effectiveModel}`
|
||||
: effectiveModel;
|
||||
|
||||
// Autofocus textarea in embedded mode
|
||||
useEffect(() => {
|
||||
if (embedded) textareaRef.current?.focus();
|
||||
}, [embedded]);
|
||||
|
||||
// Auto-scroll to bottom when messages update
|
||||
useEffect(() => {
|
||||
@@ -95,7 +156,7 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
|
||||
if (!trimmed || streaming) return;
|
||||
|
||||
const userMsg: Message = { role: "user", content: trimmed };
|
||||
const assistantMsg: Message = { role: "assistant", content: "", model: effectiveModel };
|
||||
const assistantMsg: Message = { role: "assistant", content: "", model: qualifiedModel };
|
||||
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||
setInput("");
|
||||
setStreaming(true);
|
||||
@@ -117,7 +178,7 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
|
||||
"x-connection-id": providerId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: effectiveModel,
|
||||
model: qualifiedModel,
|
||||
messages: [
|
||||
// Include history (all except the last assistant placeholder)
|
||||
...messages,
|
||||
@@ -217,8 +278,10 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
|
||||
} finally {
|
||||
setStreaming(false);
|
||||
abortRef.current = null;
|
||||
// Refocus textarea so user can keep typing
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}
|
||||
}, [input, streaming, selectedKey, apiKey, providerId, effectiveModel, messages]);
|
||||
}, [input, streaming, selectedKey, apiKey, providerId, qualifiedModel, messages]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
@@ -231,11 +294,30 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
if (streaming) handleStop();
|
||||
const handleClear = useCallback(() => {
|
||||
if (streaming) abortRef.current?.abort();
|
||||
setMessages([]);
|
||||
setStats(null);
|
||||
};
|
||||
}, [streaming]);
|
||||
|
||||
useImperativeHandle(
|
||||
controlsRef,
|
||||
() => ({
|
||||
clear: handleClear,
|
||||
hasMessages: messages.length > 0,
|
||||
streaming,
|
||||
}),
|
||||
[handleClear, messages.length, streaming]
|
||||
);
|
||||
|
||||
// Notify parent of control state changes (for external toolbar)
|
||||
useEffect(() => {
|
||||
onControlsChange?.({
|
||||
clear: handleClear,
|
||||
hasMessages: messages.length > 0,
|
||||
streaming,
|
||||
});
|
||||
}, [onControlsChange, handleClear, messages.length, streaming]);
|
||||
|
||||
const modelOptions = models.length > 0 ? models : initialModel ? [{ id: initialModel }] : [];
|
||||
|
||||
@@ -246,53 +328,55 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
|
||||
embedded ? "flex-1 min-h-0" : "rounded-lg border border-border bg-bg-card p-4"
|
||||
)}
|
||||
>
|
||||
{/* Header controls */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Model select */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<label className="text-xs text-text-muted shrink-0">{t("model")}:</label>
|
||||
<select
|
||||
value={model || firstModel}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{modelOptions.length === 0 && <option value="">{initialModel || "—"}</option>}
|
||||
{modelOptions.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Key select */}
|
||||
{keys.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-xs text-text-muted shrink-0">{t("selectKey")}:</label>
|
||||
{/* Header controls (hidden when parent renders its own toolbar) */}
|
||||
{!hideToolbar && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Model select */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<label className="text-xs text-text-muted shrink-0">{t("model")}:</label>
|
||||
<select
|
||||
value={selectedKey}
|
||||
onChange={(e) => setSelectedKey(e.target.value)}
|
||||
className="rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
value={model || firstModel}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">(default)</option>
|
||||
{keys.map((k) => (
|
||||
<option key={k.id} value={k.key}>
|
||||
{k.name ?? k.id}
|
||||
{modelOptions.length === 0 && <option value="">{initialModel || "—"}</option>}
|
||||
{modelOptions.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{/* Clear button */}
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Key select */}
|
||||
{keys.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-xs text-text-muted shrink-0">{t("selectKey")}:</label>
|
||||
<select
|
||||
value={selectedKey}
|
||||
onChange={(e) => setSelectedKey(e.target.value)}
|
||||
className="rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">(default)</option>
|
||||
{keys.map((k) => (
|
||||
<option key={k.id} value={k.key}>
|
||||
{k.name ?? k.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{/* Clear button */}
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-xs text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
@@ -385,13 +469,13 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
|
||||
{/* Input row */}
|
||||
<div className="relative flex items-end gap-2 rounded-lg border border-border bg-bg-subtle px-3 py-2 focus-within:ring-1 focus-within:ring-primary focus-within:border-primary/50 transition-colors">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
placeholder={`${t("send")}…`}
|
||||
disabled={streaming}
|
||||
className="flex-1 bg-transparent text-sm py-1.5 text-text-main placeholder:text-text-muted focus:outline-none resize-none disabled:opacity-50 max-h-32"
|
||||
className="flex-1 bg-transparent text-sm py-1.5 text-text-main placeholder:text-text-muted focus:outline-none resize-none max-h-32"
|
||||
/>
|
||||
{streaming ? (
|
||||
<button
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
|
||||
import {
|
||||
LlmChatCard,
|
||||
type LlmChatControls,
|
||||
} from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { useApiKey } from "@/app/(dashboard)/dashboard/providers/hooks/useApiKey";
|
||||
import { useProviderModels } from "@/app/(dashboard)/dashboard/providers/hooks/useProviderModels";
|
||||
|
||||
interface SlideOverProvider {
|
||||
id?: string;
|
||||
@@ -26,41 +31,16 @@ interface ProviderTestSlideOverProps {
|
||||
initialTab?: TabKey;
|
||||
}
|
||||
|
||||
type TabKey = "test" | "models" | "keys" | "logs";
|
||||
type TabKey = "test" | "logs";
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: string }[] = [
|
||||
{ key: "test", label: "Test", icon: "play_arrow" },
|
||||
{ key: "models", label: "Models", icon: "view_list" },
|
||||
{ key: "keys", label: "Keys", icon: "key" },
|
||||
{ key: "logs", label: "Logs", icon: "receipt_long" },
|
||||
];
|
||||
|
||||
export default function ProviderTestSlideOver({
|
||||
isOpen,
|
||||
onClose,
|
||||
providerId,
|
||||
provider,
|
||||
staticIconPath,
|
||||
initialTab = "test",
|
||||
}: ProviderTestSlideOverProps) {
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<ProviderTestSlideOverPanel
|
||||
onClose={onClose}
|
||||
providerId={providerId}
|
||||
provider={provider}
|
||||
staticIconPath={staticIconPath}
|
||||
initialTab={initialTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
onClose: () => void;
|
||||
providerId: string;
|
||||
provider: SlideOverProvider;
|
||||
staticIconPath?: string | null;
|
||||
initialTab: TabKey;
|
||||
export default function ProviderTestSlideOver(props: ProviderTestSlideOverProps) {
|
||||
if (!props.isOpen) return null;
|
||||
return <ProviderTestSlideOverPanel {...props} />;
|
||||
}
|
||||
|
||||
function ProviderTestSlideOverPanel({
|
||||
@@ -68,9 +48,17 @@ function ProviderTestSlideOverPanel({
|
||||
providerId,
|
||||
provider,
|
||||
staticIconPath,
|
||||
initialTab,
|
||||
}: PanelProps) {
|
||||
initialTab = "test",
|
||||
}: ProviderTestSlideOverProps) {
|
||||
const [tab, setTab] = useState<TabKey>(initialTab);
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [selectedKey, setSelectedKey] = useState<string>("");
|
||||
const [controls, setControls] = useState<LlmChatControls | null>(null);
|
||||
const onControlsChange = useCallback((c: LlmChatControls) => setControls(c), []);
|
||||
|
||||
const { keys } = useApiKey();
|
||||
const { models } = useProviderModels(providerId);
|
||||
const firstModel = models[0]?.id ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
@@ -89,6 +77,7 @@ function ProviderTestSlideOverPanel({
|
||||
}, []);
|
||||
|
||||
const color = provider.color || "#64748b";
|
||||
const modelOptions = models.length > 0 ? models : [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex justify-end">
|
||||
@@ -109,15 +98,33 @@ function ProviderTestSlideOverPanel({
|
||||
color={color}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{tab === "test" && (
|
||||
<TestToolbar
|
||||
model={model || firstModel}
|
||||
onModelChange={setModel}
|
||||
modelOptions={modelOptions}
|
||||
selectedKey={selectedKey}
|
||||
onSelectedKeyChange={setSelectedKey}
|
||||
keys={keys}
|
||||
controls={controls}
|
||||
/>
|
||||
)}
|
||||
<SlideOverTabs tab={tab} onChange={setTab} />
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
{tab === "test" && (
|
||||
<div className="flex-1 min-h-0 flex flex-col pl-4 pr-2 py-3">
|
||||
<LlmChatCard providerId={providerId} embedded />
|
||||
<LlmChatCard
|
||||
providerId={providerId}
|
||||
embedded
|
||||
hideToolbar
|
||||
model={model}
|
||||
onModelChange={setModel}
|
||||
selectedKey={selectedKey}
|
||||
onSelectedKeyChange={setSelectedKey}
|
||||
onControlsChange={onControlsChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{tab === "models" && <ModelsTab providerId={providerId} />}
|
||||
{tab === "keys" && <KeysTab provider={provider} providerId={providerId} />}
|
||||
{tab === "logs" && <LogsTab providerId={providerId} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,6 +195,73 @@ function SlideOverHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function TestToolbar({
|
||||
model,
|
||||
onModelChange,
|
||||
modelOptions,
|
||||
selectedKey,
|
||||
onSelectedKeyChange,
|
||||
keys,
|
||||
controls,
|
||||
}: {
|
||||
model: string;
|
||||
onModelChange: (m: string) => void;
|
||||
modelOptions: { id: string }[];
|
||||
selectedKey: string;
|
||||
onSelectedKeyChange: (k: string) => void;
|
||||
keys: { id: string; key: string; name?: string }[];
|
||||
controls: LlmChatControls | null;
|
||||
}) {
|
||||
const hasMessages = controls?.hasMessages ?? false;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-2 border-b border-black/5 dark:border-white/5 bg-bg-subtle/30 shrink-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
<label className="text-[11px] text-text-muted shrink-0">Model:</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{modelOptions.length === 0 && <option value="">—</option>}
|
||||
{modelOptions.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{keys.length > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[11px] text-text-muted shrink-0">Key:</label>
|
||||
<select
|
||||
value={selectedKey}
|
||||
onChange={(e) => onSelectedKeyChange(e.target.value)}
|
||||
className="rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">(default)</option>
|
||||
{keys.map((k) => (
|
||||
<option key={k.id} value={k.key}>
|
||||
{k.name ?? k.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{hasMessages && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => controls?.clear()}
|
||||
className="text-[11px] text-text-muted hover:text-text-main transition-colors flex items-center gap-1"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">delete_sweep</span>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlideOverTabs({ tab, onChange }: { tab: TabKey; onChange: (next: TabKey) => void }) {
|
||||
return (
|
||||
<div
|
||||
@@ -234,134 +308,253 @@ function TabPlaceholder({ icon, title, body }: { icon: string; title: string; bo
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelEntry {
|
||||
id: string;
|
||||
displayId?: string;
|
||||
owned_by?: string;
|
||||
interface LogEntry {
|
||||
id: string | number;
|
||||
timestamp: string;
|
||||
model?: string;
|
||||
requestedModel?: string;
|
||||
provider?: string;
|
||||
status?: number;
|
||||
duration?: number;
|
||||
tokens?: { in?: number; out?: number };
|
||||
account?: string;
|
||||
apiKey?: string;
|
||||
apiKeyName?: string;
|
||||
apiKeyId?: string;
|
||||
}
|
||||
|
||||
type ModelsState =
|
||||
function formatRequester(log: LogEntry): { label: string; title: string } {
|
||||
const name = log.apiKeyName || log.account;
|
||||
const keyHint = log.apiKey || log.apiKeyId;
|
||||
const masked =
|
||||
typeof keyHint === "string" && keyHint.length > 8
|
||||
? `${keyHint.slice(0, 4)}…${keyHint.slice(-4)}`
|
||||
: keyHint || "";
|
||||
if (name && masked) return { label: name, title: `${name} (${masked})` };
|
||||
if (name) return { label: name, title: name };
|
||||
if (masked) return { label: masked, title: keyHint || masked };
|
||||
return { label: "—", title: "unknown requester" };
|
||||
}
|
||||
|
||||
type LogsState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; models: ModelEntry[] }
|
||||
| { status: "ready"; logs: LogEntry[] }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
function ModelsTab({ providerId }: { providerId: string }) {
|
||||
const [state, setState] = useState<ModelsState>({ status: "loading" });
|
||||
function LogsTab({ providerId }: { providerId: string }) {
|
||||
const [state, setState] = useState<LogsState>({ status: "loading" });
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setState({ status: "loading" });
|
||||
});
|
||||
fetch(`/api/v1/providers/${encodeURIComponent(providerId)}/models`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = (await r.json()) as { data?: ModelEntry[] };
|
||||
if (!cancelled) setState({ status: "ready", models: data.data ?? [] });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to load" });
|
||||
}
|
||||
});
|
||||
const ctrl = new AbortController();
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const url = `/api/usage/call-logs?provider=${encodeURIComponent(providerId)}&limit=20`;
|
||||
const res = await fetch(url, { signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
const logs: LogEntry[] = Array.isArray(data) ? data : (data?.logs ?? []);
|
||||
setState({ status: "ready", logs });
|
||||
} catch (err) {
|
||||
if (cancelled || (err as { name?: string })?.name === "AbortError") return;
|
||||
setState({ status: "error", message: (err as Error).message || "Failed to load logs" });
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
const interval = setInterval(load, 2000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ctrl.abort();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [providerId]);
|
||||
}, [providerId, refreshTick]);
|
||||
|
||||
const loading = state.status === "loading";
|
||||
const error = state.status === "error" ? state.message : null;
|
||||
const models = state.status === "ready" ? state.models : [];
|
||||
const handleRefresh = useCallback(() => setRefreshTick((n) => n + 1), []);
|
||||
|
||||
if (loading) {
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<TabPlaceholder
|
||||
icon="hourglass_top"
|
||||
title="Loading models"
|
||||
body="Fetching available models from provider…"
|
||||
/>
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center text-xs text-text-muted gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
Loading logs…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return <TabPlaceholder icon="error" title="Could not load models" body={error} />;
|
||||
}
|
||||
if (models.length === 0) {
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<TabPlaceholder
|
||||
icon="view_list"
|
||||
title="No models"
|
||||
body="This provider didn't return any models."
|
||||
/>
|
||||
<TabPlaceholder icon="error" title="Failed to load logs" body={<p>{state.message}</p>} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
|
||||
<ul className="flex flex-col divide-y divide-border/60">
|
||||
{models.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between gap-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-mono text-text-main truncate" title={m.id}>
|
||||
{m.displayId || m.id}
|
||||
</p>
|
||||
{m.owned_by && <p className="text-[11px] text-text-muted">{m.owned_by}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard?.writeText(m.id)}
|
||||
title="Copy model id"
|
||||
className="p-1.5 rounded-md text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors shrink-0"
|
||||
|
||||
if (state.logs.length === 0) {
|
||||
return (
|
||||
<TabPlaceholder
|
||||
icon="receipt_long"
|
||||
title="No logs yet"
|
||||
body={
|
||||
<>
|
||||
<p>Send a test message from the Test tab — logs for this provider will appear here.</p>
|
||||
<a
|
||||
href={`/dashboard/logs?connection=${encodeURIComponent(providerId)}`}
|
||||
className="mt-2 inline-flex items-center gap-1 text-accent hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">content_copy</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
Open full logs page
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-black/5 dark:border-white/5 shrink-0">
|
||||
<span className="inline-flex items-center gap-2 text-[10px] uppercase tracking-wider text-text-muted font-medium">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="relative inline-flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75 animate-ping" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
<span className="text-emerald-500">Live</span>
|
||||
</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>tailing last {state.logs.length}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefresh}
|
||||
className="text-[10px] text-text-muted hover:text-text-main inline-flex items-center gap-1"
|
||||
title="Refresh now"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">refresh</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<ul className="flex-1 min-h-0 overflow-y-auto divide-y divide-border/40">
|
||||
{state.logs.map((log) => {
|
||||
const key = String(log.id);
|
||||
const isExpanded = expanded === key;
|
||||
const statusOk = typeof log.status === "number" && log.status >= 200 && log.status < 400;
|
||||
const statusColor = statusOk
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: typeof log.status === "number"
|
||||
? "text-red-500"
|
||||
: "text-text-muted";
|
||||
const requester = formatRequester(log);
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(isExpanded ? null : key)}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 text-left hover:bg-black/[0.03] dark:hover:bg-white/[0.03] transition-colors"
|
||||
>
|
||||
<span className={`text-[11px] font-mono shrink-0 w-10 ${statusColor}`}>
|
||||
{log.status ?? "—"}
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted shrink-0 w-20 font-mono">
|
||||
{formatRelativeTs(log.timestamp)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
<span
|
||||
className="text-xs truncate text-text-main"
|
||||
title={log.model || log.requestedModel}
|
||||
>
|
||||
{log.model || log.requestedModel || "—"}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] text-text-muted truncate font-mono"
|
||||
title={requester.title}
|
||||
>
|
||||
{requester.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[11px] text-text-muted shrink-0 font-mono w-14 text-right">
|
||||
{formatDurationMs(log.duration)}
|
||||
</span>
|
||||
<span
|
||||
className={`material-symbols-outlined text-text-muted text-[16px] shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && <LogDetail log={log} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="px-4 py-2 border-t border-black/5 dark:border-white/5 text-[10px] text-text-muted text-center shrink-0">
|
||||
<a
|
||||
href={`/dashboard/logs?connection=${encodeURIComponent(providerId)}`}
|
||||
className="inline-flex items-center gap-1 hover:text-text-main hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open full logs page
|
||||
<span className="material-symbols-outlined text-[12px]">open_in_new</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeysTab({ provider, providerId }: { provider: SlideOverProvider; providerId: string }) {
|
||||
function LogDetail({ log }: { log: LogEntry }) {
|
||||
const requester = formatRequester(log);
|
||||
const tokensIn = log.tokens?.in;
|
||||
const tokensOut = log.tokens?.out;
|
||||
const rows: { label: string; value: string; mono?: boolean }[] = [
|
||||
{ label: "Timestamp", value: new Date(log.timestamp).toLocaleString(), mono: true },
|
||||
{ label: "Status", value: String(log.status ?? "—") },
|
||||
{ label: "Duration", value: formatDurationMs(log.duration) },
|
||||
{ label: "Model", value: log.model || "—", mono: true },
|
||||
{ label: "Requested model", value: log.requestedModel || "—", mono: true },
|
||||
{ label: "Provider", value: log.provider || "—", mono: true },
|
||||
{ label: "Requester", value: requester.title, mono: true },
|
||||
{
|
||||
label: "Tokens",
|
||||
value:
|
||||
tokensIn != null || tokensOut != null ? `in ${tokensIn ?? 0} · out ${tokensOut ?? 0}` : "—",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<TabPlaceholder
|
||||
icon="key"
|
||||
title="Manage keys"
|
||||
body={
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>
|
||||
Open the full provider page to add or rotate keys for{" "}
|
||||
<span className="font-mono">{provider.name}</span>.
|
||||
</p>
|
||||
<a
|
||||
href={`/dashboard/providers/${providerId}`}
|
||||
className="inline-flex items-center gap-1.5 text-accent hover:underline self-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
Go to provider page
|
||||
</a>
|
||||
<dl className="px-4 py-3 bg-bg-subtle/40 border-t border-black/5 dark:border-white/5 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-[11px]">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="contents">
|
||||
<dt className="text-text-muted uppercase tracking-wider text-[10px] font-medium">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd className={`text-text-main min-w-0 break-words ${row.mono ? "font-mono" : ""}`}>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsTab({ providerId }: { providerId: string }) {
|
||||
return (
|
||||
<TabPlaceholder
|
||||
icon="receipt_long"
|
||||
title="Recent requests"
|
||||
body={
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>Per-provider log filtering is coming soon. For now, open the global logs view.</p>
|
||||
<a
|
||||
href={`/dashboard/logs?connection=${encodeURIComponent(providerId)}`}
|
||||
className="inline-flex items-center gap-1.5 text-accent hover:underline self-center"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
Open logs
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
function formatRelativeTs(ts: string | undefined): string {
|
||||
if (!ts) return "—";
|
||||
const date = new Date(ts);
|
||||
if (isNaN(date.getTime())) return "—";
|
||||
const diff = (Date.now() - date.getTime()) / 1000;
|
||||
if (diff < 60) return `${Math.max(0, Math.floor(diff))}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDurationMs(ms: number | undefined): string {
|
||||
if (ms == null) return "—";
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user