mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-25 15:04:19 +00:00
9ecb587ac0
* refactor(provider): use LiteLLM as unified LLM requester backend
- Replace 23+ individual requester implementations with unified litellmchat.py
- Add litellm_provider field to 27 YAML manifests for provider routing
- Delete redundant requester subclasses
- Add unit tests for LiteLLMRequester (29 tests)
- Fix num_retries parameter name (was max_retries)
- Fix exception handling order for subclass exceptions
LiteLLM provides unified API for 100+ providers, eliminating need for
provider-specific requesters.
* fix: ruff format provider.py
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(provider): simplify LiteLLM requester usage handling
- Remove unused Anthropic-specific tool schema generation
- Share completion argument construction between normal and streaming calls
- Use LiteLLM/OpenAI native usage fields for monitoring
- Collect stream token usage from LiteLLM stream_options
- Update LiteLLM requester tests for unified usage fields
* restore: restore deleted provider requester files
Restore individual provider requester implementations that were
removed in de61b5d3. These files coexist with the unified
litellmchat.py backend.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat: update requesters and improve provider selection UI
- Added `litellm_provider` field to various requesters' YAML configurations.
- Removed obsolete Python requester files for OpenRouter, PPIO, QHAIGC, ShengSuanYun, SiliconFlow, Space, TokenPony, VolcArk, and Xai.
- Introduced new requesters for Tencent and Together AI with corresponding YAML configurations and SVG icons.
- Enhanced the ProviderForm component to include a searchable dropdown for selecting providers, improving user experience.
- Updated localization files to include search provider text for both English and Chinese.
* fix(provider): align litellm rebase with master
* fix(provider): capture streaming token usage; add token observability
The LiteLLM streaming requester only captured usage when a chunk had an
empty `choices` list. Many OpenAI-compatible gateways (e.g. new-api) and
providers send the final usage payload in a chunk that still carries an
empty-delta choice, so streamed calls always recorded 0 tokens in the
monitoring logs/dashboard (non-streaming worked).
- Capture stream usage whenever a chunk carries it, regardless of choices
- Add robust _normalize_usage (dict/obj shapes, derive missing total_tokens)
- Register litellm in bootutils/deps.py (was in pyproject only)
- Add MonitoringService.get_token_statistics + /monitoring/token-statistics
endpoint: summary, per-model breakdown, token timeseries, and a
zero-token-success data-quality signal
- Add TokenMonitoring dashboard tab (summary tiles, stacked token chart,
per-model table) + i18n (en/zh)
- Regression tests for stream usage capture and usage normalization
Verified end-to-end against a real OpenAI-compatible endpoint with
gpt-5.5 and claude-opus-4-8: tokens now recorded non-zero for both
streaming and non-streaming paths.
* refactor(provider): simplify litellm capabilities
* style: simplify wrapped expressions
* feat(models): persist context metadata
* fix(provider): handle dict embeddings and openai-compatible rerank in LiteLLMRequester
- invoke_embedding: support both object- and dict-shaped response.data
entries (OpenAI-compatible gateways like new-api return dicts)
- invoke_rerank: litellm.arerank rejects the 'openai' provider, so for
openai-compatible (or unspecified) providers call the standard
Jina/Cohere-style POST /v1/rerank endpoint directly over HTTP
- accept both 'relevance_score' and 'score' fields in rerank results
- add unit tests for the openai-compatible HTTP rerank path
* feat(provider): enforce requester support_type when adding models
- frontend: AddModelPopover only shows model-type tabs (llm/embedding/
rerank) that the provider's requester declares in its manifest
support_type; ModelsDialog fetches requester manifests and maps
requester -> support_type, passed down through ProviderCard
- backend: add _validate_provider_supports guard in create_llm_model /
create_embedding_model / create_rerank_model so a model cannot be
attached to a provider whose requester does not support that type,
even if the frontend restriction is bypassed (manifests without
support_type are allowed for backward compatibility)
- manifests: correct support_type for providers that do not offer all
three model types:
- llm only: anthropic, deepseek, groq, moonshot, openrouter, xai
- llm + text-embedding: openai, gemini, mistral
- add rerank to new-api (verified working via /v1/rerank)
- set llm + text-embedding + rerank for aggregator/unknown gateways
* feat(provider): add searchable alias to requester manifests
- add a free-text 'alias' field to every requester manifest spec,
containing the vendor's English/Chinese names, pinyin, common
nicknames and flagship model-series names (e.g. moonshot -> kimi,
月之暗面; zhipu -> glm, 智谱清言)
- frontend: ProviderForm requester search now also matches against
alias (substring/contains), so searching 'kimi' surfaces Moonshot,
'硅基' surfaces SiliconFlow, etc.
- also fix support_type: openrouter (relay) supports embedding+rerank;
LangBot Space gains rerank (coming soon)
* fix(provider): make support_type guard defensive against incomplete model_mgr
- _validate_provider_supports now uses getattr to gracefully skip when
model_mgr / provider_dict / manifest lookup is unavailable, instead of
raising AttributeError (fixes unit tests that mock ap.model_mgr as a
bare SimpleNamespace)
- add TestValidateProviderSupports covering: allow supported type,
reject unsupported type, allow when support_type missing, allow when
provider unknown, degrade safely when model_mgr is incomplete
* fix(persistence): guard 0004 migration against missing llm_models table
The 0004_add_llm_model_context_length migration called
inspector.get_columns('llm_models') unconditionally, raising
NoSuchTableError when the table does not exist (e.g. migrating a
fresh/empty DB, as exercised by the integration tests where
create_all() registers no tables because the ORM models are not
imported). Every other migration guards with a table-existence check
first; add the same guard here for both upgrade and downgrade.
Also restore the test head assertion to 0004 (it had been lowered to
0003 to mask this failure).
* Merge branch 'master' into feat/litellm
Resolve conflicts:
- uv.lock: regenerated via 'uv lock' to reconcile litellm/fastuuid
(ours) with openai bump (master).
- Alembic migrations: master added 0004_add_mcp_readme while this
branch added 0004_add_llm_model_context_length, both as children of
0003 (would create multiple heads). Re-chain the litellm migration as
0005_add_llm_model_context_length with down_revision=0004_add_mcp_readme
for a single linear head. Update test head assertion accordingly.
* fix(persistence): shorten migration revision id to fit varchar(32)
PostgreSQL stores alembic_version.version_num as varchar(32).
'0005_add_llm_model_context_length' (33 chars) overflowed it, raising
StringDataRightTruncationError in the PG migration tests. Rename the
revision (and file) to '0005_add_llm_context_length' (27 chars) and
update the head assertions in both SQLite and PostgreSQL migration
tests.
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: fdc310 <2213070223@qq.com>
Co-authored-by: RockChinQ <rockchinq@gmail.com>
901 lines
40 KiB
TypeScript
901 lines
40 KiB
TypeScript
import React, { Suspense, useState, useMemo, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
ChevronRight,
|
|
ChevronDown,
|
|
ExternalLink,
|
|
RefreshCw,
|
|
MessageSquare,
|
|
Sparkles,
|
|
CheckCircle2,
|
|
} from 'lucide-react';
|
|
import OverviewCards from './components/overview-cards/OverviewCards';
|
|
import MonitoringFilters from './components/filters/MonitoringFilters';
|
|
import TokenMonitoring from './components/TokenMonitoring';
|
|
import { ExportDropdown } from './components/ExportDropdown';
|
|
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
|
|
import { useMonitoringData } from './hooks/useMonitoringData';
|
|
import { useFeedbackData } from './hooks/useFeedbackData';
|
|
import { MessageDetailsCard } from './components/MessageDetailsCard';
|
|
import { MessageContentRenderer } from './components/MessageContentRenderer';
|
|
import { FeedbackStatsCards } from './components/FeedbackCard';
|
|
import { FeedbackList } from './components/FeedbackList';
|
|
import { MessageDetails } from './types/monitoring';
|
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
|
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
|
|
|
interface RawMessageData {
|
|
id: string;
|
|
timestamp: string;
|
|
bot_id: string;
|
|
bot_name: string;
|
|
pipeline_id: string;
|
|
pipeline_name: string;
|
|
message_content: string;
|
|
session_id: string;
|
|
status: string;
|
|
level: string;
|
|
platform: string;
|
|
user_id: string;
|
|
runner_name: string;
|
|
variables: Record<string, unknown>;
|
|
}
|
|
|
|
interface RawLLMCallData {
|
|
id: string;
|
|
timestamp: string;
|
|
model_name: string;
|
|
status: string;
|
|
duration: number;
|
|
error_message: string | null;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
total_tokens: number;
|
|
}
|
|
|
|
interface RawLLMStatsData {
|
|
total_calls: number;
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
total_tokens: number;
|
|
total_duration_ms: number;
|
|
average_duration_ms: number;
|
|
}
|
|
|
|
interface RawErrorData {
|
|
id: string;
|
|
timestamp: string;
|
|
error_type: string;
|
|
error_message: string;
|
|
stack_trace: string | null;
|
|
}
|
|
|
|
function MonitoringPageContent() {
|
|
const { t } = useTranslation();
|
|
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
|
useMonitoringFilters();
|
|
const { data, loading, refetch } = useMonitoringData(filterState);
|
|
|
|
// Counter to force feedbackTimeRange recomputation on manual refresh
|
|
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
|
|
|
|
// Get time range for feedback data
|
|
const feedbackTimeRange = useMemo(() => {
|
|
const now = new Date();
|
|
let startTime: Date | null = null;
|
|
|
|
switch (filterState.timeRange) {
|
|
case 'lastHour':
|
|
startTime = new Date(now.getTime() - 60 * 60 * 1000);
|
|
break;
|
|
case 'last6Hours':
|
|
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
|
break;
|
|
case 'last24Hours':
|
|
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
break;
|
|
case 'last7Days':
|
|
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case 'last30Days':
|
|
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
break;
|
|
case 'custom':
|
|
if (filterState.customDateRange) {
|
|
startTime = filterState.customDateRange.from;
|
|
}
|
|
break;
|
|
}
|
|
|
|
const endTime =
|
|
filterState.timeRange === 'custom' && filterState.customDateRange
|
|
? filterState.customDateRange.to
|
|
: now;
|
|
|
|
return {
|
|
startTime: startTime?.toISOString(),
|
|
endTime: endTime.toISOString(),
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [filterState.timeRange, filterState.customDateRange, feedbackRefreshKey]);
|
|
|
|
// Feedback data hook
|
|
const {
|
|
feedback: feedbackList,
|
|
stats: feedbackStats,
|
|
loading: feedbackLoading,
|
|
} = useFeedbackData({
|
|
botIds:
|
|
filterState.selectedBots.length > 0
|
|
? filterState.selectedBots
|
|
: undefined,
|
|
pipelineIds:
|
|
filterState.selectedPipelines.length > 0
|
|
? filterState.selectedPipelines
|
|
: undefined,
|
|
startTime: feedbackTimeRange.startTime,
|
|
endTime: feedbackTimeRange.endTime,
|
|
limit: 50,
|
|
});
|
|
|
|
// Combined refresh handler for both monitoring and feedback data
|
|
const handleRefresh = useCallback(() => {
|
|
refetch();
|
|
setFeedbackRefreshKey((k) => k + 1);
|
|
}, [refetch]);
|
|
|
|
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [messageDetails, setMessageDetails] = useState<
|
|
Record<string, MessageDetails>
|
|
>({});
|
|
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
|
|
{},
|
|
);
|
|
|
|
// State for expanded errors
|
|
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
|
|
|
// State for controlled tabs
|
|
const [activeTab, setActiveTab] = useState<string>('messages');
|
|
|
|
// Function to jump to a message record
|
|
const jumpToMessage = async (messageId: string) => {
|
|
setActiveTab('messages');
|
|
// Small delay to ensure tab switch completes
|
|
setTimeout(() => {
|
|
toggleMessageExpand(messageId);
|
|
}, 100);
|
|
};
|
|
|
|
const toggleMessageExpand = async (messageId: string) => {
|
|
if (expandedMessageId === messageId) {
|
|
// Collapse
|
|
setExpandedMessageId(null);
|
|
} else {
|
|
// Expand
|
|
setExpandedMessageId(messageId);
|
|
|
|
// Fetch details if not already loaded
|
|
if (!messageDetails[messageId]) {
|
|
setLoadingDetails({ ...loadingDetails, [messageId]: true });
|
|
try {
|
|
// httpClient.get() returns the inner data directly (response.data.data)
|
|
const result = await httpClient.get<{
|
|
message_id: string;
|
|
found: boolean;
|
|
message: RawMessageData | null;
|
|
llm_calls: RawLLMCallData[];
|
|
llm_stats: RawLLMStatsData;
|
|
errors: RawErrorData[];
|
|
}>(`/api/v1/monitoring/messages/${messageId}/details`);
|
|
|
|
if (result) {
|
|
setMessageDetails((prev) => ({
|
|
...prev,
|
|
[messageId]: {
|
|
messageId: result.message_id,
|
|
found: result.found,
|
|
message: result.message
|
|
? {
|
|
id: result.message.id,
|
|
timestamp: new Date(result.message.timestamp),
|
|
botId: result.message.bot_id,
|
|
botName: result.message.bot_name,
|
|
pipelineId: result.message.pipeline_id,
|
|
pipelineName: result.message.pipeline_name,
|
|
messageContent: result.message.message_content,
|
|
sessionId: result.message.session_id,
|
|
status: result.message.status,
|
|
level: result.message.level,
|
|
platform: result.message.platform,
|
|
userId: result.message.user_id,
|
|
runnerName: result.message.runner_name,
|
|
variables: result.message.variables,
|
|
}
|
|
: undefined,
|
|
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
|
id: call.id,
|
|
timestamp: new Date(call.timestamp),
|
|
modelName: call.model_name,
|
|
status: call.status,
|
|
duration: call.duration,
|
|
errorMessage: call.error_message,
|
|
tokens: {
|
|
input: call.input_tokens || 0,
|
|
output: call.output_tokens || 0,
|
|
total: call.total_tokens || 0,
|
|
},
|
|
})),
|
|
errors: result.errors.map((error: RawErrorData) => ({
|
|
id: error.id,
|
|
timestamp: new Date(error.timestamp),
|
|
errorType: error.error_type,
|
|
errorMessage: error.error_message,
|
|
stackTrace: error.stack_trace,
|
|
})),
|
|
llmStats: {
|
|
totalCalls: result.llm_stats.total_calls,
|
|
totalInputTokens: result.llm_stats.total_input_tokens,
|
|
totalOutputTokens: result.llm_stats.total_output_tokens,
|
|
totalTokens: result.llm_stats.total_tokens,
|
|
totalDurationMs: result.llm_stats.total_duration_ms,
|
|
averageDurationMs: result.llm_stats.average_duration_ms,
|
|
},
|
|
} as MessageDetails,
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch message details:', error);
|
|
} finally {
|
|
setLoadingDetails({ ...loadingDetails, [messageId]: false });
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const toggleErrorExpand = (errorId: string) => {
|
|
if (expandedErrorId === errorId) {
|
|
setExpandedErrorId(null);
|
|
} else {
|
|
setExpandedErrorId(errorId);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="w-full h-full overflow-y-auto overflow-x-hidden">
|
|
{/* Filters and Refresh Button - Sticky */}
|
|
<div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
|
|
<div>
|
|
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-card rounded-xl border">
|
|
<MonitoringFilters
|
|
selectedBots={filterState.selectedBots}
|
|
selectedPipelines={filterState.selectedPipelines}
|
|
timeRange={filterState.timeRange}
|
|
onBotsChange={setSelectedBots}
|
|
onPipelinesChange={setSelectedPipelines}
|
|
onTimeRangeChange={setTimeRange}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<ExportDropdown filterState={filterState} />
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleRefresh}
|
|
className="shadow-sm flex-shrink-0"
|
|
>
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
{t('monitoring.refreshData')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
|
|
{/* Overview Section */}
|
|
<OverviewCards
|
|
metrics={data?.overview || null}
|
|
messages={data?.messages || []}
|
|
llmCalls={data?.llmCalls || []}
|
|
loading={loading}
|
|
/>
|
|
|
|
{/* Tabs Section */}
|
|
<div className="bg-card rounded-xl border overflow-hidden">
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={setActiveTab}
|
|
className="w-full"
|
|
>
|
|
<div className="px-6 pt-4">
|
|
<TabsList className="h-12 p-1">
|
|
<TabsTrigger value="messages" className="px-6 py-2">
|
|
{t('monitoring.tabs.messages')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="modelCalls" className="px-6 py-2">
|
|
{t('monitoring.tabs.modelCalls')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="tokens" className="px-6 py-2">
|
|
{t('monitoring.tabs.tokens')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="feedback" className="px-6 py-2">
|
|
{t('monitoring.tabs.feedback')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="errors" className="px-6 py-2">
|
|
{t('monitoring.tabs.errors')}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
<TabsContent value="messages" className="p-6 m-0">
|
|
<div>
|
|
{loading && (
|
|
<div className="py-12 flex justify-center">
|
|
<LoadingSpinner
|
|
text={t('monitoring.messageList.loading')}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{!loading &&
|
|
data &&
|
|
data.messages &&
|
|
data.messages.length > 0 && (
|
|
<div className="space-y-4">
|
|
{data.messages
|
|
.filter((msg) => {
|
|
// Filter out messages with empty content
|
|
const content = msg.messageContent?.trim();
|
|
return (
|
|
content && content !== '[]' && content !== '""'
|
|
);
|
|
})
|
|
.map((msg) => (
|
|
<div
|
|
key={msg.id}
|
|
className="border rounded-xl overflow-hidden transition-all duration-200"
|
|
>
|
|
{/* Message Header - Always Visible */}
|
|
<div
|
|
className="p-5 cursor-pointer hover:bg-accent transition-colors"
|
|
onClick={() => toggleMessageExpand(msg.id)}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start flex-1">
|
|
{/* Expand Icon */}
|
|
<div className="mr-3 mt-0.5">
|
|
{expandedMessageId === msg.id ? (
|
|
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="w-5 h-5 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Message Info */}
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs text-muted-foreground font-mono">
|
|
ID: {msg.id}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="font-medium text-sm text-foreground">
|
|
{msg.botName}
|
|
</span>
|
|
<span className="text-muted-foreground">
|
|
→
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{msg.pipelineName}
|
|
</span>
|
|
{msg.runnerName && (
|
|
<>
|
|
<span className="text-muted-foreground">
|
|
→
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{msg.runnerName}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="text-base text-foreground">
|
|
<MessageContentRenderer
|
|
content={msg.messageContent}
|
|
maxLines={3}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status and Timestamp */}
|
|
<div className="flex flex-col items-end gap-2 ml-4">
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{msg.timestamp.toLocaleString()}
|
|
</span>
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded ${
|
|
msg.level === 'error'
|
|
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
|
: msg.level === 'warning'
|
|
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
|
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
|
}`}
|
|
>
|
|
{msg.level}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Details */}
|
|
{expandedMessageId === msg.id && (
|
|
<div className="border-t p-4 bg-muted">
|
|
{loadingDetails[msg.id] && (
|
|
<div className="py-4 flex justify-center">
|
|
<LoadingSpinner size="sm" text="" />
|
|
</div>
|
|
)}
|
|
{!loadingDetails[msg.id] &&
|
|
messageDetails[msg.id] && (
|
|
<MessageDetailsCard
|
|
details={messageDetails[msg.id]}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!loading &&
|
|
(!data || !data.messages || data.messages.length === 0) && (
|
|
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
|
<MessageSquare className="h-[3rem] w-[3rem]" />
|
|
<div className="text-sm">
|
|
{t('monitoring.messageList.noMessages')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="modelCalls" className="p-6 m-0">
|
|
<div>
|
|
{loading && (
|
|
<div className="py-12 flex justify-center">
|
|
<LoadingSpinner text={t('common.loading')} />
|
|
</div>
|
|
)}
|
|
|
|
{!loading &&
|
|
data &&
|
|
data.modelCalls &&
|
|
data.modelCalls.length > 0 && (
|
|
<div className="space-y-4">
|
|
{data.modelCalls.map((call) => (
|
|
<div
|
|
key={call.id}
|
|
className="border rounded-xl p-5 transition-all duration-200"
|
|
>
|
|
<div className="flex justify-between items-start mb-3">
|
|
<div className="flex-1">
|
|
{/* Query ID - only show if messageId exists */}
|
|
{call.messageId && (
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs text-muted-foreground font-mono">
|
|
Query ID: {call.messageId}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1.5 text-xs"
|
|
onClick={() =>
|
|
jumpToMessage(call.messageId!)
|
|
}
|
|
>
|
|
<ExternalLink className="w-3 h-3 mr-1" />
|
|
{t(
|
|
'monitoring.messageList.viewConversation',
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-2 mb-2">
|
|
{/* Model Type Badge */}
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded ${
|
|
call.modelType === 'llm'
|
|
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
|
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
|
|
}`}
|
|
>
|
|
{call.modelType === 'llm'
|
|
? t('monitoring.modelCalls.llmModel')
|
|
: t('monitoring.modelCalls.embeddingModel')}
|
|
</span>
|
|
{/* Call Type Badge for Embedding */}
|
|
{call.modelType === 'embedding' &&
|
|
call.callType && (
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded ${
|
|
call.callType === 'retrieve'
|
|
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
|
|
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
|
|
}`}
|
|
>
|
|
{call.callType === 'retrieve'
|
|
? t(
|
|
'monitoring.modelCalls.retrieveCall',
|
|
)
|
|
: t(
|
|
'monitoring.modelCalls.embeddingCall',
|
|
)}
|
|
</span>
|
|
)}
|
|
{/* Status Badge */}
|
|
<span
|
|
className={`text-xs px-2 py-1 rounded ${
|
|
call.status === 'success'
|
|
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
|
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
|
}`}
|
|
>
|
|
{call.status}
|
|
</span>
|
|
</div>
|
|
{/* Model Name */}
|
|
<div className="font-medium text-sm text-foreground mb-2">
|
|
{call.modelName}
|
|
</div>
|
|
{/* Context Info - only for LLM calls */}
|
|
{call.modelType === 'llm' &&
|
|
call.botName &&
|
|
call.pipelineName && (
|
|
<div className="text-xs text-muted-foreground mb-1">
|
|
{call.botName} → {call.pipelineName}
|
|
</div>
|
|
)}
|
|
{/* Token Info */}
|
|
<div className="text-xs text-muted-foreground space-y-1">
|
|
<div className="flex flex-wrap gap-4">
|
|
{call.modelType === 'llm' && call.tokens && (
|
|
<>
|
|
<span>
|
|
{t('monitoring.llmCalls.inputTokens')}:{' '}
|
|
{call.tokens.input}
|
|
</span>
|
|
<span>
|
|
{t('monitoring.llmCalls.outputTokens')}:{' '}
|
|
{call.tokens.output}
|
|
</span>
|
|
<span>
|
|
{t('monitoring.llmCalls.totalTokens')}:{' '}
|
|
{call.tokens.total}
|
|
</span>
|
|
</>
|
|
)}
|
|
{call.modelType === 'embedding' && (
|
|
<>
|
|
<span>
|
|
{t(
|
|
'monitoring.embeddingCalls.promptTokens',
|
|
)}
|
|
: {call.promptTokens}
|
|
</span>
|
|
<span>
|
|
{t(
|
|
'monitoring.embeddingCalls.totalTokens',
|
|
)}
|
|
: {call.totalTokens}
|
|
</span>
|
|
<span>
|
|
{t(
|
|
'monitoring.embeddingCalls.inputCount',
|
|
)}
|
|
: {call.inputCount}
|
|
</span>
|
|
</>
|
|
)}
|
|
<span>
|
|
{t('monitoring.llmCalls.duration')}:{' '}
|
|
{call.duration}ms
|
|
</span>
|
|
{call.cost && (
|
|
<span>
|
|
{t('monitoring.llmCalls.cost')}: $
|
|
{call.cost.toFixed(4)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* Knowledge Base Info for Embedding */}
|
|
{call.modelType === 'embedding' &&
|
|
call.knowledgeBaseId && (
|
|
<div>
|
|
{t(
|
|
'monitoring.embeddingCalls.knowledgeBase',
|
|
)}
|
|
: {call.knowledgeBaseId}
|
|
</div>
|
|
)}
|
|
{/* Query Text for Embedding Retrieve */}
|
|
{call.modelType === 'embedding' &&
|
|
call.queryText && (
|
|
<div className="mt-2 p-2 bg-muted rounded text-sm">
|
|
<span className="text-muted-foreground">
|
|
{t(
|
|
'monitoring.embeddingCalls.queryText',
|
|
)}
|
|
:{' '}
|
|
</span>
|
|
<span className="text-foreground">
|
|
{call.queryText.length > 100
|
|
? call.queryText.substring(0, 100) +
|
|
'...'
|
|
: call.queryText}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{call.errorMessage && (
|
|
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
|
|
Error: {call.errorMessage}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
|
{call.timestamp.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!loading &&
|
|
(!data ||
|
|
!data.modelCalls ||
|
|
data.modelCalls.length === 0) && (
|
|
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
|
<Sparkles className="h-[3rem] w-[3rem]" />
|
|
<div className="text-sm">
|
|
{t('monitoring.modelCalls.noData')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="tokens" className="p-6 m-0">
|
|
<TokenMonitoring
|
|
botIds={
|
|
filterState.selectedBots.length > 0
|
|
? filterState.selectedBots
|
|
: undefined
|
|
}
|
|
pipelineIds={
|
|
filterState.selectedPipelines.length > 0
|
|
? filterState.selectedPipelines
|
|
: undefined
|
|
}
|
|
startTime={feedbackTimeRange.startTime}
|
|
endTime={feedbackTimeRange.endTime}
|
|
refreshKey={feedbackRefreshKey}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="feedback" className="p-6 m-0">
|
|
<div>
|
|
{loading && (
|
|
<div className="py-12 flex justify-center">
|
|
<LoadingSpinner text={t('common.loading')} />
|
|
</div>
|
|
)}
|
|
|
|
{!loading && (
|
|
<>
|
|
{/* Feedback Stats Cards */}
|
|
<div className="mb-6">
|
|
<FeedbackStatsCards
|
|
stats={feedbackStats}
|
|
loading={feedbackLoading}
|
|
/>
|
|
</div>
|
|
|
|
{/* Feedback List */}
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
{t('monitoring.feedback.feedbackList')}
|
|
</h3>
|
|
<FeedbackList
|
|
feedback={feedbackList}
|
|
loading={feedbackLoading}
|
|
onViewMessage={jumpToMessage}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="errors" className="p-6 m-0">
|
|
<div>
|
|
{loading && (
|
|
<div className="py-12 flex justify-center">
|
|
<LoadingSpinner text={t('common.loading')} />
|
|
</div>
|
|
)}
|
|
|
|
{!loading && data && data.errors && data.errors.length > 0 && (
|
|
<div className="space-y-4">
|
|
{data.errors.map((error) => (
|
|
<div
|
|
key={error.id}
|
|
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
|
>
|
|
{/* Error Header - Always Visible */}
|
|
<div
|
|
className="p-5 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30"
|
|
onClick={() => toggleErrorExpand(error.id)}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start flex-1">
|
|
{/* Expand Icon */}
|
|
<div className="mr-3 mt-0.5">
|
|
{expandedErrorId === error.id ? (
|
|
<ChevronDown className="w-5 h-5 text-red-500" />
|
|
) : (
|
|
<ChevronRight className="w-5 h-5 text-red-500" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Error Info */}
|
|
<div className="flex-1">
|
|
{/* Query ID */}
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs text-muted-foreground font-mono">
|
|
Query ID: {error.messageId || '-'}
|
|
</span>
|
|
{error.messageId && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1.5 text-xs"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
jumpToMessage(error.messageId!);
|
|
}}
|
|
>
|
|
<ExternalLink className="w-3 h-3 mr-1" />
|
|
{t(
|
|
'monitoring.messageList.viewConversation',
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="font-medium text-sm text-red-700 dark:text-red-300">
|
|
{error.errorType}
|
|
</span>
|
|
<span className="text-red-400">→</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{error.botName}
|
|
</span>
|
|
<span className="text-red-400">→</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{error.pipelineName}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
|
|
{error.errorMessage}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timestamp */}
|
|
<div className="flex flex-col items-end gap-2 ml-4">
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
{error.timestamp.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Details */}
|
|
{expandedErrorId === error.id && (
|
|
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
|
|
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
|
|
{/* Error Details */}
|
|
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
|
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
|
|
{t('monitoring.errors.errorMessage')}
|
|
</h4>
|
|
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
|
|
{error.errorMessage}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Context Info */}
|
|
<div className="bg-muted rounded-lg p-3">
|
|
<h4 className="text-sm font-semibold text-foreground mb-3">
|
|
{t('monitoring.messageList.viewDetails')}
|
|
</h4>
|
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
|
<div className="bg-background rounded p-2">
|
|
<div className="text-muted-foreground">
|
|
{t('monitoring.messageList.bot')}
|
|
</div>
|
|
<div className="font-medium text-foreground">
|
|
{error.botName}
|
|
</div>
|
|
</div>
|
|
<div className="bg-background rounded p-2">
|
|
<div className="text-muted-foreground">
|
|
{t('monitoring.messageList.pipeline')}
|
|
</div>
|
|
<div className="font-medium text-foreground">
|
|
{error.pipelineName}
|
|
</div>
|
|
</div>
|
|
{error.sessionId && (
|
|
<div className="bg-background rounded p-2">
|
|
<div className="text-muted-foreground">
|
|
{t('monitoring.sessions.sessionId')}
|
|
</div>
|
|
<div className="font-medium text-foreground truncate">
|
|
{error.sessionId}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stack Trace */}
|
|
{error.stackTrace && (
|
|
<div className="bg-muted rounded-lg p-3">
|
|
<h4 className="text-sm font-semibold text-foreground mb-3">
|
|
{t('monitoring.errors.stackTrace')}
|
|
</h4>
|
|
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
|
|
{error.stackTrace}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!loading &&
|
|
(!data || !data.errors || data.errors.length === 0) && (
|
|
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
|
<CheckCircle2 className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600" />
|
|
<div className="text-sm text-green-600 dark:text-green-400">
|
|
{t('monitoring.errors.noErrors')}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function MonitoringPage() {
|
|
return (
|
|
<Suspense fallback={<LoadingPage />}>
|
|
<MonitoringPageContent />
|
|
</Suspense>
|
|
);
|
|
}
|