Merge remote-tracking branch 'origin/master' into feat/card_dify_human_input

This commit is contained in:
fdc310
2026-07-12 18:39:48 +08:00
792 changed files with 91351 additions and 16881 deletions
+9
View File
@@ -0,0 +1,9 @@
import { Outlet } from 'react-router-dom';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
// Top-level route layout: drives the dynamic document title from the active
// route and renders the matched child route via <Outlet />.
export default function RootLayout() {
useDocumentTitle();
return <Outlet />;
}
+9
View File
@@ -74,6 +74,15 @@
}
}
/* Hide scrollbar while keeping scroll behaviour (horizontal tag/filter rows). */
.scrollbar-hide {
-ms-overflow-style: none; /* IE / Edge */
scrollbar-width: none; /* Firefox */
}
.scrollbar-hide::-webkit-scrollbar {
display: none; /* Chrome / Safari / WebKit */
}
@custom-variant dark (&:is(.dark *));
@theme inline {
File diff suppressed because it is too large Load Diff
+41 -39
View File
@@ -191,45 +191,47 @@ export default function BotDetailContent({ id }: { id: string }) {
onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0"
>
<TabsList className="shrink-0">
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
{activeTab === 'sessions' && (
<button
type="button"
className="inline-flex items-center justify-center ml-0.5"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (isRefreshingSessions) return;
setIsRefreshingSessions(true);
const minDelay = new Promise((r) => setTimeout(r, 500));
Promise.all([
sessionMonitorRef.current?.refreshSessions(),
minDelay,
]).finally(() => setIsRefreshingSessions(false));
}}
>
<RefreshCw
className={cn(
'size-3 text-muted-foreground hover:text-foreground transition-colors',
isRefreshingSessions && 'animate-spin',
)}
/>
</button>
)}
</TabsTrigger>
</TabsList>
<div className="flex shrink-0 items-center gap-1">
<TabsList>
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
</TabsTrigger>
</TabsList>
{activeTab === 'sessions' && (
<button
type="button"
aria-label={t('bots.sessionMonitor.refresh')}
title={t('bots.sessionMonitor.refresh')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
disabled={isRefreshingSessions}
onClick={() => {
if (isRefreshingSessions) return;
setIsRefreshingSessions(true);
const minDelay = new Promise((r) => setTimeout(r, 500));
Promise.all([
sessionMonitorRef.current?.refreshSessions(),
minDelay,
]).finally(() => setIsRefreshingSessions(false));
}}
>
<RefreshCw
className={cn(
'size-3.5',
isRefreshingSessions && 'animate-spin',
)}
/>
</button>
)}
</div>
{/* Tab: Configuration */}
<TabsContent
@@ -0,0 +1,199 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Trash2, Plus, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
export interface BotAdmin {
id: number;
launcher_type: string;
launcher_id: string;
}
interface BotAdminsDialogProps {
botId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
admins: BotAdmin[];
onAdminsChange: () => void;
}
export default function BotAdminsDialog({
botId,
open,
onOpenChange,
admins,
onAdminsChange,
}: BotAdminsDialogProps) {
const { t } = useTranslation();
const [newType, setNewType] = useState('person');
const [newId, setNewId] = useState('');
const [adding, setAdding] = useState(false);
async function handleAdd() {
if (!newId.trim()) return;
setAdding(true);
try {
await httpClient.addBotAdmin(botId, newType, newId.trim());
toast.success(t('bots.admins.addSuccess'));
setNewId('');
onAdminsChange();
} catch (e: unknown) {
const err = e as { msg?: string; message?: string };
toast.error(t('bots.admins.addError') + (err?.msg ?? err?.message ?? ''));
} finally {
setAdding(false);
}
}
async function handleDelete(id: number) {
try {
await httpClient.deleteBotAdmin(botId, id);
toast.success(t('bots.admins.deleteSuccess'));
onAdminsChange();
} catch (e: unknown) {
const err = e as { msg?: string; message?: string };
toast.error(
t('bots.admins.deleteError') + (err?.msg ?? err?.message ?? ''),
);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="size-4" />
{t('bots.admins.title')}
</DialogTitle>
<DialogDescription>{t('bots.admins.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Add row */}
<div className="flex gap-2 items-center">
<Select value={newType} onValueChange={setNewType}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="person">
{t('bots.admins.typePerson')}
</SelectItem>
<SelectItem value="group">
{t('bots.admins.typeGroup')}
</SelectItem>
</SelectContent>
</Select>
<Input
className="flex-1"
placeholder={t('bots.admins.placeholderId')}
value={newId}
onChange={(e) => setNewId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
<Button
size="sm"
onClick={handleAdd}
disabled={adding || !newId.trim()}
>
<Plus className="size-4 mr-1" />
{t('bots.admins.addAdmin')}
</Button>
</div>
{/* List */}
{admins.length === 0 ? (
<div className="text-sm text-muted-foreground py-6 text-center">
{t('bots.admins.noAdmins')}
</div>
) : (
<ScrollArea className="max-h-64">
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-28">
{t('bots.admins.launcherType')}
</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">
{t('bots.admins.launcherId')}
</th>
<th className="w-10" />
</tr>
</thead>
<tbody>
{admins.map((admin) => (
<tr
key={admin.id}
className="border-b last:border-0 hover:bg-muted/30"
>
<td className="px-3 py-2">
<span className="px-1.5 py-0.5 rounded bg-muted text-xs">
{admin.launcher_type === 'person'
? t('bots.admins.typePerson')
: t('bots.admins.typeGroup')}
</span>
</td>
<td className="px-3 py-2 font-mono">
{admin.launcher_id}
</td>
<td className="px-2 py-2">
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => handleDelete(admin.id)}
>
<Trash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ScrollArea>
)}
</div>
</DialogContent>
</Dialog>
);
}
// Shared hook so the session monitor and the dialog stay in sync.
export function useBotAdmins(botId: string) {
const [admins, setAdmins] = useState<BotAdmin[]>([]);
const reload = useCallback(async () => {
try {
const res = await httpClient.getBotAdmins(botId);
setAdmins(res.admins ?? []);
} catch (error) {
console.error('Failed to load bot admins:', error);
}
}, [botId]);
useEffect(() => {
reload();
}, [reload]);
return { admins, reload };
}
@@ -4,6 +4,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { Switch } from '@/components/ui/switch';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { MessageSquare, Workflow } from 'lucide-react';
export default function BotCard({
botCardVO,
@@ -42,28 +43,14 @@ export default function BotCard({
</div>
<div className={`${styles.basicInfoAdapterContainer}`}>
<svg
className={`${styles.basicInfoAdapterIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 8.99374C2 5.68349 4.67654 3 8.00066 3H15.9993C19.3134 3 22 5.69478 22 8.99374V21H8.00066C4.68659 21 2 18.3052 2 15.0063V8.99374ZM20 19V8.99374C20 6.79539 18.2049 5 15.9993 5H8.00066C5.78458 5 4 6.78458 4 8.99374V15.0063C4 17.2046 5.79512 19 8.00066 19H20ZM14 11H16V13H14V11ZM8 11H10V13H8V11Z"></path>
</svg>
<MessageSquare className={`${styles.basicInfoAdapterIcon}`} />
<span className={`${styles.basicInfoAdapterLabel}`}>
{botCardVO.adapterLabel}
</span>
</div>
<div className={`${styles.basicInfoPipelineContainer}`}>
<svg
className={`${styles.basicInfoPipelineIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path>
</svg>
<Workflow className={`${styles.basicInfoPipelineIcon}`} />
<span className={`${styles.basicInfoPipelineLabel}`}>
{botCardVO.usePipelineName}
</span>
@@ -13,6 +13,7 @@ import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import { UUID } from 'uuidjs';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Bot } from '@/app/infra/entities/api';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink } from 'lucide-react';
@@ -625,6 +626,7 @@ export default function BotForm({
extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
outbound_ips: systemInfo.outbound_ips,
}}
/>
)}
@@ -48,7 +48,6 @@ interface PipelineOption {
}
interface RoutingRulesEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: UseFormReturn<any>;
pipelineNameList: PipelineOption[];
}
@@ -3,6 +3,7 @@ import React, {
useEffect,
useRef,
useCallback,
useMemo,
forwardRef,
useImperativeHandle,
} from 'react';
@@ -15,10 +16,20 @@ import {
Bot,
Copy,
Check,
ChevronDown,
ChevronRight,
Workflow,
ThumbsUp,
ThumbsDown,
ShieldCheck,
ShieldOff,
Wrench,
} from 'lucide-react';
import { toast } from 'sonner';
import BotAdminsDialog, {
useBotAdmins,
} from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
import type { BotAdmin } from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
MessageChainComponent,
@@ -69,6 +80,35 @@ interface SessionFeedback {
stream_id?: string | null;
}
interface SessionToolCall {
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
message_id?: string | null;
arguments?: string | null;
result?: string | null;
error_message?: string | null;
}
type SessionTimelineItem =
| {
id: string;
type: 'message';
timestamp: number;
order: number;
message: SessionMessage;
}
| {
id: string;
type: 'tool';
timestamp: number;
order: number;
toolCall: SessionToolCall;
};
export interface BotSessionMonitorHandle {
refreshSessions: () => Promise<void>;
}
@@ -93,16 +133,65 @@ const BotSessionMonitor = forwardRef<
const [feedbackMap, setFeedbackMap] = useState<
Record<string, SessionFeedback>
>({});
const [toolCalls, setToolCalls] = useState<SessionToolCall[]>([]);
const [expandedToolCallIds, setExpandedToolCallIds] = useState<
Record<string, boolean>
>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
const parseSessionType = (sessionId: string): string | null => {
const idx = sessionId.indexOf('_');
if (idx === -1) return null;
const type = sessionId.slice(0, idx);
if (type === 'person' || type === 'group') return type;
const lower = sessionId.toLowerCase();
if (lower.includes('person')) return 'person';
if (lower.includes('group')) return 'group';
return null;
};
const isSessionAdmin = (session: SessionInfo): boolean => {
const type = parseSessionType(session.session_id);
const lid =
session.user_id ??
session.session_id.replace(
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
'',
);
return admins.some(
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
);
};
const toggleAdmin = async (session: SessionInfo) => {
const type = parseSessionType(session.session_id);
if (!type) return;
const lid =
session.user_id ??
session.session_id.replace(
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
'',
);
const key = session.session_id;
setTogglingAdmin(key);
try {
const existing = admins.find(
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
);
if (existing) {
await httpClient.deleteBotAdmin(botId, existing.id);
toast.success(t('bots.admins.deleteSuccess'));
} else {
await httpClient.addBotAdmin(botId, type, lid);
toast.success(t('bots.admins.addSuccess'));
}
await reloadAdmins();
} catch {
toast.error(t('bots.admins.addError'));
} finally {
setTogglingAdmin(null);
}
};
const abbreviateId = (id: string): string => {
if (id.length <= 10) return id;
return `${id.slice(0, 4)}..${id.slice(-4)}`;
@@ -137,6 +226,7 @@ const BotSessionMonitor = forwardRef<
const loadMessages = useCallback(
async (sessionId: string) => {
setLoadingMessages(true);
setExpandedToolCallIds({});
try {
const messagesRes = await httpClient.getSessionMessages(sessionId);
const sorted = (messagesRes.messages ?? []).sort(
@@ -145,6 +235,18 @@ const BotSessionMonitor = forwardRef<
);
setMessages(sorted);
try {
const analysisRes = await httpClient.get<{
tool_calls?: SessionToolCall[];
}>(
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
);
setToolCalls(analysisRes?.tool_calls ?? []);
} catch (analysisError) {
console.error('Failed to load session tool calls:', analysisError);
setToolCalls([]);
}
// Collect user message IDs for feedback matching
const userMsgIds = new Set(
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
@@ -188,11 +290,14 @@ const BotSessionMonitor = forwardRef<
loadMessages(selectedSessionId);
} else {
setMessages([]);
setToolCalls([]);
setExpandedToolCallIds({});
setFeedbackMap({});
}
}, [selectedSessionId, loadMessages]);
useEffect(() => {
if (messages.length === 0) return;
if (messages.length === 0 && toolCalls.length === 0) return;
// Wait for DOM to render the new messages before scrolling
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
@@ -204,7 +309,7 @@ const BotSessionMonitor = forwardRef<
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
});
}, [messages]);
}, [messages, toolCalls]);
const parseMessageChain = (content: string): MessageChainComponent[] => {
try {
@@ -379,262 +484,510 @@ const BotSessionMonitor = forwardRef<
return `${diffDays}d`;
};
const formatDuration = (durationMs: number): string => {
if (!durationMs) return '0ms';
if (durationMs < 1000) return `${durationMs}ms`;
return `${(durationMs / 1000).toFixed(2)}s`;
};
const truncateToolDetail = (value?: string | null): string => {
if (!value) return '';
return value.length > 600 ? `${value.slice(0, 600)}...` : value;
};
const toggleToolCallDetails = (toolCallId: string) => {
setExpandedToolCallIds((previous) => ({
...previous,
[toolCallId]: !previous[toolCallId],
}));
};
const feedbackByMessageId = useMemo(() => {
const map: Record<string, SessionFeedback> = {};
for (let index = 0; index < messages.length; index++) {
const msg = messages[index];
if (isUserMessage(msg)) continue;
for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) {
const previousMessage = messages[previousIndex];
if (isUserMessage(previousMessage)) {
const feedback = feedbackMap[previousMessage.id];
if (feedback) {
map[msg.id] = feedback;
}
break;
}
}
}
return map;
}, [feedbackMap, messages]);
const timelineItems = useMemo<SessionTimelineItem[]>(() => {
const messageItems: SessionTimelineItem[] = messages.map(
(message, index) => ({
id: `message-${message.id}`,
type: 'message',
timestamp: parseTimestamp(message.timestamp).getTime(),
order: index * 2,
message,
}),
);
const toolItems: SessionTimelineItem[] = toolCalls.map(
(toolCall, index) => ({
id: `tool-${toolCall.id}`,
type: 'tool',
timestamp: parseTimestamp(toolCall.timestamp).getTime(),
order: index * 2 + 1,
toolCall,
}),
);
return [...messageItems, ...toolItems].sort(
(a, b) => a.timestamp - b.timestamp || a.order - b.order,
);
}, [messages, toolCalls]);
const selectedSession = sessions.find(
(s) => s.session_id === selectedSessionId,
);
return (
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
{/* Left Panel: Session List */}
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
{/* Session List */}
<ScrollArea className="flex-1 min-h-0">
{loadingSessions && sessions.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t('bots.sessionMonitor.loading')}
</div>
) : sessions.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noSessions')}
<>
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
{/* Left Panel: Session List */}
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
{/* Admin header */}
<div className="px-2 py-1.5 border-b shrink-0 flex items-center justify-between">
<button
type="button"
className="inline-flex items-center gap-1.5 text-sm font-medium hover:text-foreground transition-colors"
onClick={() => setAdminsDialogOpen(true)}
>
<ShieldCheck className="size-4" />
<span>
{t('bots.admins.configureAdmins')}
{admins.length > 0 && (
<span className="ml-1 tabular-nums text-xs text-muted-foreground">
({admins.length})
</span>
)}
</span>
</button>
</div>
{/* Session List */}
<ScrollArea className="flex-1 min-h-0">
{loadingSessions && sessions.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t('bots.sessionMonitor.loading')}
</div>
) : sessions.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noSessions')}
</div>
) : (
<div className="p-1.5">
{sessions.map((session) => {
const isSelected = selectedSessionId === session.session_id;
const sessionType = parseSessionType(session.session_id);
const sessionIsAdmin = isSessionAdmin(session);
return (
<div
key={session.session_id}
role="button"
tabIndex={0}
className={cn(
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)}
onClick={() => setSelectedSessionId(session.session_id)}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium truncate mr-2">
{session.user_name ||
session.user_id ||
session.session_id.slice(0, 12)}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
{formatRelativeTime(session.last_activity)}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{sessionType && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{sessionType}
</span>
)}
{session.user_id && (
<span className="truncate text-[10px]">
{abbreviateId(session.user_id)}
</span>
)}
{session.is_active && (
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
</span>
)}
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
{/* Right Panel: Messages */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{!selectedSessionId ? (
<div className="text-center text-muted-foreground text-sm flex-1 flex items-center justify-center">
{t('bots.sessionMonitor.selectSession')}
</div>
) : (
<div className="p-1.5">
{sessions.map((session) => {
const isSelected = selectedSessionId === session.session_id;
return (
<button
key={session.session_id}
type="button"
className={cn(
'w-full text-left px-2.5 py-2 rounded-md transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
<>
{/* Chat Header */}
<div className="px-4 py-2.5 border-b shrink-0 flex items-center justify-between">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">
{selectedSession?.user_name ||
selectedSession?.user_id ||
selectedSessionId.slice(0, 20)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
onClick={() => setSelectedSessionId(session.session_id)}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium truncate mr-2">
{session.user_name ||
session.user_id ||
session.session_id.slice(0, 12)}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
{formatRelativeTime(session.last_activity)}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{parseSessionType(session.session_id) && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{parseSessionType(session.session_id)}
{selectedSession?.user_id && (
<>
<span>·</span>
<span className="font-mono">
{selectedSession.user_id}
</span>
)}
{session.platform && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{session.platform}
</span>
)}
{session.user_id && (
<span className="truncate text-[10px]">
{abbreviateId(session.user_id)}
</span>
)}
{session.is_active && (
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
<button
type="button"
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
>
{copiedUserId ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</>
)}
{selectedSession?.is_active && (
<>
<span>·</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Active
</span>
)}
</div>
</button>
);
})}
</div>
)}
</ScrollArea>
</div>
{/* Right Panel: Messages */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{!selectedSessionId ? (
<div className="text-center text-muted-foreground text-sm flex-1 flex items-center justify-center">
{t('bots.sessionMonitor.selectSession')}
</div>
) : (
<>
{/* Chat Header */}
<div className="px-4 py-2.5 border-b shrink-0">
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{selectedSession?.user_name ||
selectedSession?.user_id ||
selectedSessionId.slice(0, 20)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
{selectedSession?.platform && (
<>
{parseSessionType(selectedSessionId) && <span>·</span>}
<span>{selectedSession.platform}</span>
</>
)}
{selectedSession?.user_id && (
<>
<span>·</span>
<span className="font-mono">
{selectedSession.user_id}
</span>
<button
type="button"
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
>
{copiedUserId ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</>
)}
{selectedSession?.is_active && (
<>
<span>·</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Active
</span>
</>
)}
</>
)}
{selectedSession && parseSessionType(selectedSessionId) && (
<>
<span>·</span>
<button
type="button"
className={cn(
'inline-flex items-center gap-1 transition-colors',
isSessionAdmin(selectedSession)
? 'text-blue-500'
: 'text-muted-foreground hover:text-blue-500',
)}
disabled={togglingAdmin === selectedSessionId}
title={
isSessionAdmin(selectedSession)
? t('bots.admins.removeAdminTitle')
: t('bots.admins.setAdminTitle')
}
onClick={() => toggleAdmin(selectedSession)}
>
{isSessionAdmin(selectedSession) ? (
<ShieldCheck className="size-3.5" />
) : (
<ShieldOff className="size-3.5" />
)}
<span>{t('bots.admins.adminBadge')}</span>
</button>
</>
)}
</div>
</div>
</div>
</div>
{/* Messages Area */}
<ScrollArea
ref={messagesContainerRef}
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
>
<div className="space-y-4">
{loadingMessages ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')}
</div>
) : messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')}
</div>
) : (
messages.map((msg, msgIndex) => {
const isUser = isUserMessage(msg);
const isDiscarded =
msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD;
// For bot replies, find feedback linked to the preceding user message
let msgFeedback: SessionFeedback | undefined;
if (!isUser) {
for (let i = msgIndex - 1; i >= 0; i--) {
if (isUserMessage(messages[i])) {
msgFeedback = feedbackMap[messages[i].id];
break;
}
{/* Messages Area */}
<ScrollArea
ref={messagesContainerRef}
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
>
<div className="space-y-4">
{loadingMessages ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')}
</div>
) : timelineItems.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')}
</div>
) : (
timelineItems.map((item) => {
if (item.type === 'tool') {
const call = item.toolCall;
const hasToolDetails = Boolean(
call.arguments || call.result || call.error_message,
);
const expandedToolCall = Boolean(
expandedToolCallIds[call.id],
);
const detailsId = `tool-call-details-${call.id}`;
return (
<div key={item.id} className="flex justify-start">
<div className="max-w-2xl rounded-xl rounded-bl-sm border border-border/60 bg-muted/25 px-2.5 py-1.5 text-xs text-muted-foreground">
<button
type="button"
className={cn(
'flex w-full items-center justify-between gap-3 rounded-md text-left outline-none transition-colors',
hasToolDetails &&
'cursor-pointer hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring',
)}
aria-expanded={
hasToolDetails ? expandedToolCall : undefined
}
aria-controls={
hasToolDetails ? detailsId : undefined
}
aria-disabled={!hasToolDetails}
onClick={() =>
hasToolDetails &&
toggleToolCallDetails(call.id)
}
>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{hasToolDetails &&
(expandedToolCall ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
))}
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0 max-w-[18rem] truncate text-[13px] font-medium text-foreground/75">
{call.tool_name}
</span>
<span className="rounded border border-border/50 bg-background/60 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{call.tool_source}
</span>
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-medium leading-none',
call.status === 'success'
? 'bg-green-100/70 text-green-700 dark:bg-green-950/60 dark:text-green-300'
: 'bg-red-100/70 text-red-700 dark:bg-red-950/60 dark:text-red-300',
)}
>
{call.status}
</span>
</div>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground/80">
{formatDuration(call.duration)}
</span>
</button>
{hasToolDetails && expandedToolCall && (
<div
id={detailsId}
className="mt-2 space-y-1.5"
>
{(call.arguments || call.result) && (
<div className="space-y-1.5">
{call.arguments && (
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
{t(
'monitoring.toolCalls.arguments',
{
defaultValue: '参数',
},
)}
</div>
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
{truncateToolDetail(call.arguments)}
</pre>
</div>
)}
{call.result && (
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
{t('monitoring.toolCalls.result', {
defaultValue: '结果',
})}
</div>
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
{truncateToolDetail(call.result)}
</pre>
</div>
)}
</div>
)}
{call.error_message && (
<div className="whitespace-pre-wrap break-words rounded bg-red-50 p-2 text-[11px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
{call.error_message}
</div>
)}
</div>
)}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span>
{t('monitoring.toolCalls.title', {
defaultValue: '工具调用',
})}
</span>
<span className="tabular-nums">
{formatTime(call.timestamp)}
</span>
{hasToolDetails && (
<>
<span>·</span>
<span>
{expandedToolCall
? t(
'monitoring.toolCalls.hideDetails',
{
defaultValue: '隐藏详情',
},
)
: t(
'monitoring.toolCalls.showDetails',
{
defaultValue: '查看详情',
},
)}
</span>
</>
)}
</div>
</div>
</div>
);
}
}
return (
<div
key={msg.id}
className={cn(
'flex',
isUser ? 'justify-end' : 'justify-start',
)}
>
const msg = item.message;
const isUser = isUserMessage(msg);
const isDiscarded =
msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD;
const msgFeedback = feedbackByMessageId[msg.id];
return (
<div
key={item.id}
className={cn(
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
isUser
? 'bg-primary/10 rounded-br-sm'
: 'bg-muted rounded-bl-sm',
msg.status === 'error' && 'ring-1 ring-red-400/50',
isDiscarded && 'opacity-60',
'flex',
isUser ? 'justify-end' : 'justify-start',
)}
>
{renderMessageContent(msg)}
{/* Role label + pipeline + timestamp */}
<div
className={cn(
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
isUser
? 'bg-primary/10 rounded-br-sm'
: 'bg-muted rounded-bl-sm',
msg.status === 'error' &&
'ring-1 ring-red-400/50',
isDiscarded && 'opacity-60',
)}
>
<span>
{isUser
? t('bots.sessionMonitor.userMessage', {
defaultValue: 'User',
})
: t('bots.sessionMonitor.botMessage', {
defaultValue: 'Assistant',
{renderMessageContent(msg)}
{/* Role label + pipeline + timestamp */}
<div
className={cn(
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
)}
>
<span>
{isUser
? t('bots.sessionMonitor.userMessage', {
defaultValue: 'User',
})
: t('bots.sessionMonitor.botMessage', {
defaultValue: 'Assistant',
})}
</span>
<span className="tabular-nums">
{formatTime(msg.timestamp)}
</span>
{isDiscarded ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<Ban className="w-3 h-3" />
{t('bots.sessionMonitor.discarded', {
defaultValue: 'Discarded',
})}
</span>
<span className="tabular-nums">
{formatTime(msg.timestamp)}
</span>
{isDiscarded ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<Ban className="w-3 h-3" />
{t('bots.sessionMonitor.discarded', {
defaultValue: 'Discarded',
})}
</span>
) : msg.pipeline_name ? (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Workflow className="w-3 h-3" />
{msg.pipeline_name}
</span>
) : null}
{msg.status === 'error' && (
<span className="text-red-500">error</span>
)}
{msg.runner_name && (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Bot className="w-3 h-3" />
{msg.runner_name}
</span>
)}
{/* Feedback indicator — same line, pushed right */}
{!isUser &&
msgFeedback &&
(msgFeedback.feedback_type === 1 ? (
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.like')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span>
) : (
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group">
<ThumbsDown className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.dislike')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
) : msg.pipeline_name ? (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Workflow className="w-3 h-3" />
{msg.pipeline_name}
</span>
))}
) : null}
{msg.status === 'error' && (
<span className="text-red-500">error</span>
)}
{msg.runner_name && (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Bot className="w-3 h-3" />
{msg.runner_name}
</span>
)}
{/* Feedback indicator — same line, pushed right */}
{!isUser &&
msgFeedback &&
(msgFeedback.feedback_type === 1 ? (
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.like')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span>
) : (
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group">
<ThumbsDown className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.dislike')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span>
))}
</div>
</div>
</div>
</div>
);
})
)}
</div>
</ScrollArea>
</>
)}
);
})
)}
</div>
</ScrollArea>
</>
)}
</div>
</div>
</div>
<BotAdminsDialog
botId={botId}
open={adminsDialogOpen}
onOpenChange={setAdminsDialogOpen}
admins={admins}
onAdminsChange={reloadAdmins}
/>
</>
);
});
@@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next';
import { Info, ShieldAlert } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
/**
* Banner shown when a feature depends on the Box sandbox runtime but it is
* currently disabled in config or otherwise unavailable. Pass the ``hint``
* key returned by ``useBoxStatus`` (``'boxDisabled' | 'boxUnavailable'``).
*
* Renders nothing when there is no hint — safe to drop at the top of any
* page that may or may not need to surface the notice.
*/
export interface BoxUnavailableNoticeProps {
hint: 'boxDisabled' | 'boxUnavailable' | null;
/** Specific failure reason from the backend (``connector_error``). Shown
* on a dedicated line so the user sees WHY (e.g. ``Configured sandbox
* backend "nsjail" is unavailable``) instead of just the generic
* "unavailable" wording. Ignored when ``hint === 'boxDisabled'``
* because the disabled-by-config message already carries the reason. */
reason?: string | null;
className?: string;
}
export function BoxUnavailableNotice({
hint,
reason,
className,
}: BoxUnavailableNoticeProps) {
const { t } = useTranslation();
if (!hint) return null;
const variant = hint === 'boxDisabled' ? 'default' : 'destructive';
const Icon = hint === 'boxDisabled' ? Info : ShieldAlert;
const showReason = hint === 'boxUnavailable' && reason;
return (
<Alert variant={variant} className={className}>
<Icon className="h-4 w-4" />
<AlertDescription className="space-y-1">
<div>{t(`monitoring.${hint}`)}</div>
{showReason && (
<div className="text-xs font-mono opacity-80 break-all">{reason}</div>
)}
<div className="text-xs opacity-80">
{t('monitoring.boxRequiredHint')}
</div>
</AlertDescription>
</Alert>
);
}
export default BoxUnavailableNotice;
@@ -1,208 +0,0 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
interface AccountSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function AccountSettingsDialog({
open,
onOpenChange,
}: AccountSettingsDialogProps) {
const { t } = useTranslation();
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [hasPassword, setHasPassword] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
useEffect(() => {
if (open) {
loadUserInfo();
}
}, [open]);
async function loadUserInfo() {
setLoading(true);
try {
const info = await httpClient.getUserInfo();
setAccountType(info.account_type);
setHasPassword(info.has_password);
setUserEmail(info.user);
} catch {
toast.error(t('common.error'));
} finally {
setLoading(false);
}
}
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
setSpaceBindLoading(false);
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
// Pass token as state for security verification
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
setSpaceBindLoading(false);
}
};
const handlePasswordDialogClose = (dialogOpen: boolean) => {
setPasswordDialogOpen(dialogOpen);
if (!dialogOpen) {
// Reload user info to update password status
loadUserInfo();
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('account.settings')}</DialogTitle>
<DialogDescription>{userEmail}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
{/* Password Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<KeyRound className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<ItemDescription>
{hasPassword
? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={() => setPasswordDialogOpen(true)}
disabled={!systemInfo.allow_modify_login_info}
>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</Button>
</ItemActions>
</Item>
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L2 7L12 12L22 7L12 2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17L12 22L22 17"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 12L12 17L22 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
<ItemDescription>
{accountType === 'space'
? t('account.spaceBoundDescription')
: t('account.bindSpaceDescription')}
</ItemDescription>
</ItemContent>
{accountType === 'local' && (
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={handleBindSpace}
disabled={
spaceBindLoading || !systemInfo.allow_modify_login_info
}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button>
</ItemActions>
)}
</Item>
</div>
)}
</DialogContent>
</Dialog>
<PasswordChangeDialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</>
);
}
@@ -0,0 +1,171 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
interface AccountSettingsPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
onEmailResolved?: (email: string) => void;
}
export default function AccountSettingsPanel({
active,
onEmailResolved,
}: AccountSettingsPanelProps) {
const { t } = useTranslation();
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [hasPassword, setHasPassword] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
useEffect(() => {
if (active) {
loadUserInfo();
}
}, [active]);
async function loadUserInfo() {
setLoading(true);
try {
const info = await httpClient.getUserInfo();
setAccountType(info.account_type);
setHasPassword(info.has_password);
setUserEmail(info.user);
onEmailResolved?.(info.user);
} catch {
toast.error(t('common.error'));
} finally {
setLoading(false);
}
}
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
setSpaceBindLoading(false);
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
// Pass token as state for security verification
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
setSpaceBindLoading(false);
}
};
const handlePasswordDialogClose = (dialogOpen: boolean) => {
setPasswordDialogOpen(dialogOpen);
if (!dialogOpen) {
// Reload user info to update password status
loadUserInfo();
}
};
return (
<PanelBody>
{userEmail && (
<p className="mb-4 text-sm text-muted-foreground">{userEmail}</p>
)}
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
{/* Password Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<KeyRound className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<ItemDescription>
{hasPassword
? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={() => setPasswordDialogOpen(true)}
disabled={!systemInfo.allow_modify_login_info}
>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</Button>
</ItemActions>
</Item>
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<Layers className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
<ItemDescription>
{accountType === 'space'
? t('account.spaceBoundDescription')
: t('account.bindSpaceDescription')}
</ItemDescription>
</ItemContent>
{accountType === 'local' && (
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={handleBindSpace}
disabled={
spaceBindLoading || !systemInfo.allow_modify_login_info
}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button>
</ItemActions>
)}
</Item>
</div>
)}
<PasswordChangeDialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</PanelBody>
);
}
@@ -3,7 +3,6 @@ import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import {
Dialog,
DialogContent,
@@ -37,6 +36,7 @@ import {
} from '@/components/ui/alert-dialog';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
interface ApiKey {
id: number;
@@ -55,20 +55,15 @@ interface Webhook {
created_at: string;
}
interface ApiIntegrationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interface ApiIntegrationPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
}
export default function ApiIntegrationDialog({
open,
onOpenChange,
}: ApiIntegrationDialogProps) {
export default function ApiIntegrationPanel({
active,
}: ApiIntegrationPanelProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -91,33 +86,12 @@ export default function ApiIntegrationDialog({
);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Sync URL with dialog state
useEffect(() => {
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showApiIntegrationSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
}
}, [open]);
// MCP server endpoint, derived from the current origin. The backend serves
// the MCP server at /mcp on the same host/port as the HTTP API + web UI.
const mcpEndpoint =
typeof window !== 'undefined' ? `${window.location.origin}/mcp` : '/mcp';
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
return;
}
if (!newOpen) {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
onOpenChange(newOpen);
};
// 清理 body 样式,防止对话框关闭后页面无法交互
// 清理 body 样式,防止嵌套对话框关闭后页面无法交互
useEffect(() => {
if (!deleteKeyId && !deleteWebhookId) {
const cleanup = () => {
@@ -131,11 +105,11 @@ export default function ApiIntegrationDialog({
}, [deleteKeyId, deleteWebhookId]);
useEffect(() => {
if (open) {
if (active) {
loadApiKeys();
loadWebhooks();
}
}, [open]);
}, [active]);
const loadApiKeys = async () => {
setLoading(true);
@@ -284,233 +258,275 @@ export default function ApiIntegrationDialog({
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
<DialogHeader>
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full flex-1 flex flex-col overflow-hidden"
>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
{t('common.apiKeys')}
</TabsTrigger>
<TabsTrigger
className="px-5 py-4 cursor-pointer"
value="webhooks"
>
{t('common.webhooks')}
</TabsTrigger>
</TabsList>
{/* API Keys Tab */}
<TabsContent
value="apikeys"
className="space-y-4 flex-1 flex flex-col overflow-hidden"
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex h-full min-h-0 w-full flex-col overflow-hidden"
>
<PanelToolbar>
<TabsList>
<TabsTrigger value="apikeys">{t('common.apiKeys')}</TabsTrigger>
<TabsTrigger value="webhooks">{t('common.webhooks')}</TabsTrigger>
<TabsTrigger value="mcp">{t('common.mcpTab')}</TabsTrigger>
</TabsList>
{activeTab === 'apikeys' ? (
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md overflow-auto flex-1">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{item.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* Webhooks Tab */}
<TabsContent
value="webhooks"
className="space-y-4 flex-1 flex flex-col overflow-hidden"
>
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="border rounded-md overflow-auto flex-1 max-w-full">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() =>
handleToggleWebhook(webhook)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close')}
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : activeTab === 'webhooks' ? (
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
) : null}
</PanelToolbar>
{/* API Keys Tab */}
<TabsContent
value="apikeys"
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
>
<p className="text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</p>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="flex-1 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{item.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* Webhooks Tab */}
<TabsContent
value="webhooks"
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
>
<p className="text-sm text-muted-foreground">
{t('common.webhookHint')}
</p>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="max-w-full flex-1 overflow-auto rounded-md border">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() => handleToggleWebhook(webhook)}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* MCP Tab */}
<TabsContent
value="mcp"
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
>
<p className="text-sm text-muted-foreground">{t('common.mcpHint')}</p>
<div className="space-y-2">
<label className="text-sm font-medium">
{t('common.mcpEndpoint')}
</label>
<div className="flex items-center gap-2">
<code className="flex-1 truncate rounded bg-muted px-3 py-2 text-sm">
{mcpEndpoint}
</code>
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(mcpEndpoint)}
title={t('common.copy')}
>
{copiedKey === mcpEndpoint ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t('common.mcpAuthTitle')}
</label>
<p className="text-sm text-muted-foreground">
{t('common.mcpAuthDesc')}
</p>
<pre className="overflow-auto rounded bg-muted px-3 py-2 text-xs">
{`X-API-Key: <your-api-key>
# or
Authorization: Bearer <your-api-key>`}
</pre>
<p className="text-sm text-muted-foreground">
{t('common.mcpGlobalKeyNote')}
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
{t('common.mcpClientConfigTitle')}
</label>
<pre className="overflow-auto rounded bg-muted px-3 py-2 text-xs">
{`{
"mcpServers": {
"langbot": {
"url": "${mcpEndpoint}",
"headers": { "X-API-Key": "<your-api-key>" }
}
}
}`}
</pre>
</div>
</TabsContent>
</Tabs>
{/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
@@ -1,4 +1,8 @@
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import {
DynamicFormItemType,
IDynamicFormItemSchema,
SYSTEM_FIELD_PREFIX,
} from '@/app/infra/entities/form/dynamic';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -24,11 +28,18 @@ import {
Copy,
Check,
Globe,
Info,
QrCode,
Download,
ExternalLink,
} from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
@@ -46,8 +57,8 @@ function resolveShowIfValue(
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith('__system.')) {
const key = field.slice('__system.'.length);
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
const key = field.slice(SYSTEM_FIELD_PREFIX.length);
return systemContext?.[key];
}
if (watchedValues[field] !== undefined) {
@@ -56,6 +67,95 @@ function resolveShowIfValue(
return externalDependentValues?.[field];
}
type DynamicFormValueSpec = Pick<
IDynamicFormItemSchema,
'default' | 'name' | 'required' | 'type'
>;
function getValueSpecs(item: IDynamicFormItemSchema): DynamicFormValueSpec[] {
if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) {
return [
item,
{
name: 'enable-all-tools',
type: DynamicFormItemType.BOOLEAN,
required: false,
default: true,
},
];
}
if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) {
return [
item,
{
name: 'mcp-resources',
type: DynamicFormItemType.UNKNOWN,
required: false,
default: [],
},
{
name: 'mcp-resource-agent-read-enabled',
type: DynamicFormItemType.BOOLEAN,
required: false,
default: true,
},
];
}
return [item];
}
function getValueSchema(spec: DynamicFormValueSpec) {
if (spec.name === 'mcp-resources') {
return z.array(z.any());
}
switch (spec.type) {
case DynamicFormItemType.INT:
return z.number();
case DynamicFormItemType.FLOAT:
return z.number();
case DynamicFormItemType.BOOLEAN:
return z.boolean();
case DynamicFormItemType.STRING:
return z.string();
case DynamicFormItemType.STRING_ARRAY:
return z.array(z.string());
case DynamicFormItemType.SELECT:
return z.string();
case DynamicFormItemType.LLM_MODEL_SELECTOR:
return z.string();
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
return z.string();
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
return z.string();
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
return z.string();
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
case DynamicFormItemType.RESOURCES_SELECTOR:
case DynamicFormItemType.RICH_TOOLS_SELECTOR:
case DynamicFormItemType.TOOLS_SELECTOR:
return z.array(z.string());
case DynamicFormItemType.BOT_SELECTOR:
return z.string();
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR:
return z.object({
primary: z.string(),
fallbacks: z.array(z.string()),
});
case DynamicFormItemType.PROMPT_EDITOR:
return z.array(
z.object({
content: z.string(),
role: z.string(),
}),
);
default:
return z.string();
}
}
/**
* Display-only component for embed code fields with copy animation.
*/
@@ -131,13 +231,13 @@ function WebhookUrlField({
};
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex items-center gap-2">
<FormItem className="min-w-0">
<FormLabel className="break-words">{label}</FormLabel>
<div className="flex min-w-0 items-center gap-2">
<Input
value={url}
readOnly
className="flex-1 bg-muted"
className="min-w-0 flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
@@ -154,11 +254,11 @@ function WebhookUrlField({
</Button>
</div>
{extraUrl && (
<div className="flex items-center gap-2 mt-2">
<div className="mt-2 flex min-w-0 items-center gap-2">
<Input
value={extraUrl}
readOnly
className="flex-1 bg-muted"
className="min-w-0 flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
@@ -176,12 +276,14 @@ function WebhookUrlField({
</div>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
<p className="text-sm break-words text-muted-foreground">
{description}
</p>
)}
{systemInfo.edition === 'community' && (
<div className="flex items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 mt-1 max-w-2xl">
<div className="mt-1 flex max-w-full min-w-0 items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5">
<Globe className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground leading-relaxed">
<p className="text-sm leading-relaxed break-words text-muted-foreground">
{t('bots.webhookSaasHint')}{' '}
<a
href="https://space.langbot.app/cloud?utm_source=local_webui&utm_medium=webhook_alert&utm_campaign=saas_conversion"
@@ -217,9 +319,9 @@ function DownloadLinkField({
const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`;
return (
<FormItem>
<FormLabel>{label}</FormLabel>
<div className="flex items-center gap-2">
<FormItem className="min-w-0">
<FormLabel className="break-words">{label}</FormLabel>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={downloadUrl} download={filename}>
<Download className="h-4 w-4" />
@@ -236,12 +338,103 @@ function DownloadLinkField({
)}
</div>
{description && (
<p className="text-sm text-muted-foreground max-w-2xl">{description}</p>
<p className="max-w-2xl text-sm break-words text-muted-foreground">
{description}
</p>
)}
</FormItem>
);
}
/**
* Display-only component for `__system.*` fields (e.g. the deployment's
* outbound IPs that the operator must add to a platform's trusted-IP list).
* Renders one read-only row per value, each with a copy button. Rendered
* outside of react-hook-form binding since the values come from
* systemContext, not user input.
*/
function SystemInfoField({
label,
description,
values,
}: {
label: string;
description?: string;
values: string[];
}) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const handleCopy = (text: string, index: number) => {
copyToClipboard(text).catch(() => {});
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
};
return (
<FormItem className="min-w-0">
<FormLabel className="break-words">{label}</FormLabel>
<div className="space-y-2">
{values.map((value, index) => (
<div key={index} className="flex min-w-0 items-center gap-2">
<Input
value={value}
readOnly
className="min-w-0 flex-1 bg-muted"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleCopy(value, index)}
>
{copiedIndex === index ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
))}
</div>
{description && (
<p className="text-sm break-words text-muted-foreground">
{description}
</p>
)}
</FormItem>
);
}
// Hover-only Radix tooltips never open on touch devices (no pointer hover),
// so the ``disabled_tooltip`` explaining why a field is locked was invisible on
// mobile. This wrapper makes the info icon also toggle the tooltip on tap while
// keeping hover behavior on desktop.
function DisabledTooltipIcon({ text }: { text: string }) {
const [open, setOpen] = useState(false);
return (
<TooltipProvider delayDuration={100}>
<Tooltip open={open} onOpenChange={setOpen}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={text}
className="inline-flex shrink-0"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen((v) => !v);
}}
>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help shrink-0" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">{text}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export default function DynamicFormComponent({
itemConfigList,
onSubmit,
@@ -274,9 +467,16 @@ export default function DynamicFormComponent({
// model-fallback-selector) is coerced to the expected shape
// so that downstream components never crash.
const normalizeFieldValue = (
item: IDynamicFormItemSchema,
item: DynamicFormValueSpec,
value: unknown,
): unknown => {
if (
item.name === 'mcp-resources' ||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
) {
return Array.isArray(value) ? value : [];
}
if (item.type === 'model-fallback-selector') {
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
@@ -305,8 +505,9 @@ export default function DynamicFormComponent({
return value;
};
// Filter out display-only field types (e.g. webhook-url, embed-code) that should not
// participate in form state, validation, or value emission.
// Filter out display-only fields (webhook-url/embed-code/qr-code-login types
// and `__system.*`-named fields) that should not participate in form state,
// validation, or value emission.
const editableItems = useMemo(
() =>
itemConfigList.filter(
@@ -314,73 +515,22 @@ export default function DynamicFormComponent({
item.type !== 'webhook-url' &&
item.type !== 'embed-code' &&
item.type !== 'qr-code-login' &&
item.type !== 'download-link',
item.type !== 'download-link' &&
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
),
[itemConfigList],
);
const editableValueSpecs = useMemo(
() => editableItems.flatMap(getValueSpecs),
[editableItems],
);
// 根据 itemConfigList 动态生成 zod schema
const formSchema = z.object(
editableItems.reduce(
editableValueSpecs.reduce(
(acc, item) => {
let fieldSchema;
switch (item.type) {
case 'integer':
fieldSchema = z.number();
break;
case 'float':
fieldSchema = z.number();
break;
case 'boolean':
fieldSchema = z.boolean();
break;
case 'string':
fieldSchema = z.string();
break;
case 'array[string]':
fieldSchema = z.array(z.string());
break;
case 'select':
fieldSchema = z.string();
break;
case 'llm-model-selector':
fieldSchema = z.string();
break;
case 'embedding-model-selector':
fieldSchema = z.string();
break;
case 'rerank-model-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-multi-selector':
fieldSchema = z.array(z.string());
break;
case 'bot-selector':
fieldSchema = z.string();
break;
case 'tools-selector':
fieldSchema = z.array(z.string());
break;
case 'model-fallback-selector':
fieldSchema = z.object({
primary: z.string(),
fallbacks: z.array(z.string()),
});
break;
case 'prompt-editor':
fieldSchema = z.array(
z.object({
content: z.string(),
role: z.string(),
}),
);
break;
default:
fieldSchema = z.string();
}
let fieldSchema = getValueSchema(item);
if (
item.required &&
@@ -405,7 +555,7 @@ export default function DynamicFormComponent({
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: editableItems.reduce((acc, item) => {
defaultValues: editableValueSpecs.reduce((acc, item) => {
// 优先使用 initialValues,如果没有则使用默认值
const rawValue = initialValues?.[item.name] ?? item.default;
return {
@@ -444,7 +594,7 @@ export default function DynamicFormComponent({
if (initialValues && hasRealChange) {
// 合并默认值和初始值
const mergedValues = editableItems.reduce(
const mergedValues = editableValueSpecs.reduce(
(acc, item) => {
const rawValue = initialValues[item.name] ?? item.default;
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
@@ -459,10 +609,16 @@ export default function DynamicFormComponent({
previousInitialValues.current = initialValues;
}
}, [initialValues, form, editableItems]);
}, [initialValues, form, editableValueSpecs]);
// Get reactive form values for conditional rendering
const watchedValues = form.watch();
const setFormValue = (name: string, value: unknown) => {
form.setValue(name as keyof FormValues, value as never, {
shouldDirty: true,
shouldValidate: true,
});
};
// Stable ref for onSubmit to avoid re-triggering the effect when the
// parent passes a new closure on every render.
@@ -475,7 +631,7 @@ export default function DynamicFormComponent({
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = editableItems.reduce(
const initialFinalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -495,7 +651,7 @@ export default function DynamicFormComponent({
const subscription = form.watch(() => {
const formValues = form.getValues();
const finalValues = editableItems.reduce(
const finalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
@@ -506,7 +662,7 @@ export default function DynamicFormComponent({
previousInitialValues.current = finalValues as Record<string, object>;
});
return () => subscription.unsubscribe();
}, [form, editableItems]);
}, [form, editableValueSpecs]);
// State for QR code login dialog
const [qrDialogOpen, setQrDialogOpen] = useState(false);
@@ -515,7 +671,7 @@ export default function DynamicFormComponent({
return (
<Form {...form}>
<div className="space-y-4">
<div className="min-w-0 max-w-full space-y-4 overflow-x-hidden">
{/* QR code login dialog */}
<QrCodeLoginDialog
open={qrDialogOpen}
@@ -560,8 +716,69 @@ export default function DynamicFormComponent({
}
}
// All fields are disabled when editing (creation_settings are immutable)
const isFieldDisabled = !!isEditing;
// ``disable_if`` mirrors ``show_if``'s evaluator but instead of
// hiding the field, leaves it visible and inert. Use it when the
// operator needs to see that the field exists yet cannot edit it
// under the current runtime state (e.g. sandbox-bound fields when
// Box is disabled).
let isDisabledByCondition = false;
if (config.disable_if) {
const dependValue = resolveShowIfValue(
config.disable_if.field,
watchedValues as Record<string, unknown>,
externalDependentValues,
systemContext,
);
const cond = config.disable_if;
if (cond.operator === 'eq' && dependValue === cond.value) {
isDisabledByCondition = true;
} else if (cond.operator === 'neq' && dependValue !== cond.value) {
isDisabledByCondition = true;
} else if (
cond.operator === 'in' &&
Array.isArray(cond.value) &&
cond.value.includes(dependValue)
) {
isDisabledByCondition = true;
}
}
// All fields are disabled when editing (creation_settings are
// immutable) or when ``disable_if`` matches.
const isFieldDisabled = !!isEditing || isDisabledByCondition;
const disabledTooltip =
isDisabledByCondition && config.disabled_tooltip
? extractI18nObject(config.disabled_tooltip)
: '';
const renderDisabledTooltipIcon = () =>
disabledTooltip ? (
<DisabledTooltipIcon text={disabledTooltip} />
) : null;
// `__system.*` fields are display-only; their value is resolved
// from systemContext (same namespace as show_if), not user input.
// Hidden entirely when the deployment doesn't provide the value.
if (config.name.startsWith(SYSTEM_FIELD_PREFIX)) {
const rawValue =
systemContext?.[config.name.slice(SYSTEM_FIELD_PREFIX.length)];
const values = (Array.isArray(rawValue) ? rawValue : [rawValue])
.filter((v) => v !== undefined && v !== null && v !== '')
.map(String);
if (values.length === 0) return null;
return (
<SystemInfoField
key={config.id}
label={extractI18nObject(config.label)}
description={
config.description
? extractI18nObject(config.description)
: undefined
}
values={values}
/>
);
}
// Webhook URL fields are display-only; render outside of form binding
if (config.type === 'webhook-url') {
@@ -700,6 +917,41 @@ export default function DynamicFormComponent({
);
}
if (
config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
config.type === DynamicFormItemType.RESOURCES_SELECTOR
) {
return (
<FormField
key={config.id}
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem className="min-w-0">
<FormControl>
<div
className={cn(
'min-w-0 max-w-full overflow-x-hidden',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<DynamicFormItemComponent
config={config}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (
@@ -708,19 +960,20 @@ export default function DynamicFormComponent({
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem>
<FormItem className="min-w-0">
<div
className={cn(
'flex flex-row items-center justify-between rounded-lg border p-4 max-w-2xl',
'flex w-full min-w-0 max-w-full flex-row items-center justify-between rounded-lg border p-4',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<div className="space-y-0.5">
<FormLabel className="text-base">
<div className="min-w-0 space-y-0.5">
<FormLabel className="flex min-w-0 items-center gap-1.5 text-base">
{extractI18nObject(config.label)}
{renderDisabledTooltipIcon()}
</FormLabel>
{config.description && (
<p className="text-sm text-muted-foreground">
<p className="text-sm break-words text-muted-foreground">
{extractI18nObject(config.description)}
</p>
)}
@@ -729,7 +982,10 @@ export default function DynamicFormComponent({
<DynamicFormItemComponent
config={config}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</FormControl>
</div>
@@ -746,26 +1002,35 @@ export default function DynamicFormComponent({
control={form.control}
name={config.name as keyof FormValues}
render={({ field }) => (
<FormItem>
<FormLabel>
{extractI18nObject(config.label)}{' '}
{config.required && <span className="text-red-500">*</span>}
<FormItem className="min-w-0">
<FormLabel className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 break-words">
{extractI18nObject(config.label)}{' '}
{config.required && (
<span className="text-red-500">*</span>
)}
</span>
{renderDisabledTooltipIcon()}
</FormLabel>
<FormControl>
<div
className={
isFieldDisabled ? 'pointer-events-none opacity-60' : ''
}
className={cn(
'min-w-0 max-w-full overflow-x-hidden',
isFieldDisabled && 'pointer-events-none opacity-60',
)}
>
<DynamicFormItemComponent
config={config}
field={field}
formValues={watchedValues as Record<string, unknown>}
onFileUploaded={onFileUploaded}
setFormValue={setFormValue}
systemContext={systemContext}
/>
</div>
</FormControl>
{config.description && (
<p className="text-sm text-muted-foreground">
<p className="text-sm break-words text-muted-foreground">
{extractI18nObject(config.description)}
</p>
)}
@@ -61,17 +61,36 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
import SettingsDialog, {
SettingsSection,
} from '@/app/home/components/settings-dialog/SettingsDialog';
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
function hasUsableUuid<T extends { uuid?: string | null }>(
item: T,
): item is T & { uuid: string } {
return typeof item.uuid === 'string' && item.uuid.trim().length > 0;
}
function hasUsableOptionName(option: { name?: string | null }): boolean {
return typeof option.name === 'string' && option.name.trim().length > 0;
}
export default function DynamicFormItemComponent({
config,
field,
formValues,
onFileUploaded,
setFormValue,
systemContext,
}: {
config: IDynamicFormItemSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
field: ControllerRenderProps<any, any>;
formValues?: Record<string, unknown>;
onFileUploaded?: (fileKey: string) => void;
setFormValue?: (name: string, value: unknown) => void;
systemContext?: Record<string, unknown>;
}) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
@@ -88,22 +107,48 @@ export default function DynamicFormItemComponent({
);
const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
const fetchLlmModels = () => {
httpClient
.getProviderLLMModels()
.then((resp) => {
setLlmModels(resp.models);
setLlmModels(resp.models.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('models.getModelListError') + err.msg);
});
};
const fetchEmbeddingModels = () => {
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
setEmbeddingModels(resp.models.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg);
});
};
const fetchRerankModels = () => {
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models.filter(hasUsableUuid));
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
});
};
const handleModelsDialogChange = (open: boolean) => {
setModelsDialogOpen(open);
if (!open) {
fetchLlmModels();
fetchEmbeddingModels();
fetchRerankModels();
}
};
@@ -171,27 +216,13 @@ export default function DynamicFormItemComponent({
useEffect(() => {
if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
setEmbeddingModels(resp.models);
})
.catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg);
});
fetchEmbeddingModels();
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) {
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
});
fetchRerankModels();
}
}, [config.type]);
@@ -209,7 +240,7 @@ export default function DynamicFormItemComponent({
httpClient
.getKnowledgeBases()
.then((resp) => {
setKnowledgeBases(resp.bases);
setKnowledgeBases(resp.bases.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
@@ -222,7 +253,7 @@ export default function DynamicFormItemComponent({
httpClient
.getBots()
.then((resp) => {
setBots(resp.bots);
setBots(resp.bots.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('bots.getBotListError') + err.msg);
@@ -245,13 +276,23 @@ export default function DynamicFormItemComponent({
}
}, [config.type]);
const handleCompositePatch = (patch: Record<string, unknown>) => {
for (const [name, value] of Object.entries(patch)) {
if (setFormValue) {
setFormValue(name, value);
} else if (name === field.name) {
field.onChange(value);
}
}
};
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
return (
<Input
type="number"
className="max-w-xs"
className="w-full max-w-xs"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
/>
@@ -260,8 +301,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.STRING:
if (config.options && config.options.length > 0) {
return (
<div className="flex items-center gap-1.5 max-w-md">
<Input className="flex-1" {...field} />
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<Input className="min-w-0 flex-1" {...field} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -292,21 +333,26 @@ export default function DynamicFormItemComponent({
</div>
);
}
return <Input className="max-w-md" {...field} />;
return <Input className="w-full max-w-md" {...field} />;
case DynamicFormItemType.TEXT:
return <Textarea {...field} className="min-h-[120px] max-w-2xl" />;
return (
<Textarea
{...field}
className="min-h-[120px] w-full max-w-full resize-y overflow-x-hidden break-all"
/>
);
case DynamicFormItemType.BOOLEAN:
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
case DynamicFormItemType.STRING_ARRAY:
return (
<div className="space-y-2 max-w-md">
<div className="w-full max-w-md min-w-0 space-y-2">
{field.value.map((item: string, index: number) => (
<div key={index} className="flex gap-1.5 items-center">
<div key={index} className="flex min-w-0 items-center gap-1.5">
<Input
className="flex-1"
className="min-w-0 flex-1"
value={item}
onChange={(e) => {
const newValue = [...field.value];
@@ -347,12 +393,12 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT:
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="w-full max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{config.options?.map((option) => (
{config.options?.filter(hasUsableOptionName).map((option) => (
<SelectItem
key={option.name}
value={option.name}
@@ -369,10 +415,10 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Separate space models from regular models
const spaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const regularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
// Group regular models by provider
@@ -409,10 +455,10 @@ export default function DynamicFormItemComponent({
];
return (
<div className="max-w-md flex items-center gap-1.5">
<div className="flex-1">
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
@@ -473,7 +519,7 @@ export default function DynamicFormItemComponent({
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div className="relative min-h-10">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
@@ -550,23 +596,34 @@ export default function DynamicFormItemComponent({
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
onClick={() => {
setSettingsSection('models');
setModelsDialogOpen(true);
}}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip>
<ModelsDialog
<SettingsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
section={settingsSection}
onSectionChange={setSettingsSection}
/>
</div>
);
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
// Group embedding models by provider
const groupedEmbeddingModels = embeddingModels.reduce(
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: {
const spaceEmbeddingModels = embeddingModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const regularEmbeddingModels = embeddingModels.filter(
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const groupedEmbeddingModels = regularEmbeddingModels.reduce(
(acc, model) => {
const providerName = model.provider?.name || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
@@ -576,29 +633,169 @@ export default function DynamicFormItemComponent({
{} as Record<string, EmbeddingModel[]>,
);
const groupedSpaceEmbeddingModels = spaceEmbeddingModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, EmbeddingModel[]>,
);
const previewEmbeddingModelNames = [
'text-embedding-3-large',
'text-embedding-3-small',
'bge-m3',
'jina-embeddings-v3',
'qwen3-embedding-8b',
];
return (
<div className="max-w-md">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('knowledge.selectEmbeddingModel')}
/>
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{(spaceEmbeddingModels.length > 0
? spaceEmbeddingModels.map((m) => m.name)
: previewEmbeddingModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
<div className="relative min-h-10">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(spaceEmbeddingModels.length > 0
? spaceEmbeddingModels.map((m) => m.name)
: previewEmbeddingModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
),
)}
</SelectContent>
</Select>
) : !systemInfo.disable_models_service ? (
Object.entries(groupedSpaceEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)
) : null}
</SelectContent>
</Select>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
setSettingsSection('models');
setModelsDialogOpen(true);
}}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip>
<SettingsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
section={settingsSection}
onSectionChange={setSettingsSection}
/>
</div>
);
}
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
const groupedRerankModels = rerankModels.reduce(
@@ -612,12 +809,12 @@ export default function DynamicFormItemComponent({
);
return (
<div className="max-w-md">
<div className="w-full max-w-md min-w-0">
<Select
value={field.value || '__none__'}
onValueChange={(v) => field.onChange(v === '__none__' ? '' : v)}
>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.rerank')} />
</SelectTrigger>
<SelectContent>
@@ -642,10 +839,10 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Separate space models from regular models
const fbSpaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const fbRegularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
// Group regular models by provider
@@ -713,7 +910,7 @@ export default function DynamicFormItemComponent({
placeholder: string,
) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
@@ -776,7 +973,7 @@ export default function DynamicFormItemComponent({
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div className="relative min-h-10">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
@@ -879,14 +1076,14 @@ export default function DynamicFormItemComponent({
};
return (
<div className="space-y-3">
<div className="w-full min-w-0 space-y-3">
{/* Primary model selector */}
<div>
<p className="text-xs text-muted-foreground mb-1">
{t('models.fallback.primary')}
</p>
<div className="flex items-center gap-1.5">
<div className="flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
@@ -909,25 +1106,27 @@ export default function DynamicFormItemComponent({
{t('models.title')}
</TooltipContent>
</Tooltip>
<ModelsDialog
<SettingsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
section={settingsSection}
onSectionChange={setSettingsSection}
/>
</div>
</div>
{/* Fallback models */}
{modelValue.fallbacks.length > 0 && (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
<p className="text-xs text-muted-foreground">
{t('models.fallback.fallbackList')}
</p>
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
<div key={index} className="flex items-center gap-2">
<div key={index} className="flex min-w-0 items-center gap-2">
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}.
</span>
<div className="flex-1">
<div className="min-w-0 flex-1">
{renderModelSelect(
fbUuid,
(val) => updateFallbackModel(index, val),
@@ -987,7 +1186,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
// Group KBs by Knowledge Engine name
const kbsByEngine = knowledgeBases.reduce(
const validKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
const kbsByEngine = validKnowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name)
@@ -998,25 +1198,27 @@ export default function DynamicFormItemComponent({
acc[engineName].push(kb);
return acc;
},
{} as Record<string, typeof knowledgeBases>,
{} as Record<string, typeof validKnowledgeBases>,
);
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
{field.value && field.value !== '__none__' ? (
(() => {
const selectedKb = knowledgeBases.find(
const selectedKb = validKnowledgeBases.find(
(kb) => kb.uuid === field.value,
);
return (
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
{selectedKb?.emoji && (
<span className="text-sm shrink-0">
{selectedKb.emoji}
</span>
)}
<span>{selectedKb?.name ?? field.value}</span>
<span className="truncate">
{selectedKb?.name ?? field.value}
</span>
</div>
);
})()
@@ -1033,7 +1235,7 @@ export default function DynamicFormItemComponent({
<SelectGroup key={engineName}>
<SelectLabel>{engineName}</SelectLabel>
{kbs.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
<SelectItem key={base.uuid} value={base.uuid}>
<div className="flex items-center gap-2">
{base.emoji && (
<span className="text-sm shrink-0">{base.emoji}</span>
@@ -1050,7 +1252,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
// Group KBs by Knowledge Engine name for multi-selector
const multiKbsByEngine = knowledgeBases.reduce(
const validMultiKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
const multiKbsByEngine = validMultiKnowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name)
@@ -1061,16 +1264,16 @@ export default function DynamicFormItemComponent({
acc[engineName].push(kb);
return acc;
},
{} as Record<string, typeof knowledgeBases>,
{} as Record<string, typeof validMultiKnowledgeBases>,
);
return (
<>
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{field.value.map((kbId: string) => {
const currentKb = knowledgeBases.find(
const currentKb = validMultiKnowledgeBases.find(
(base) => base.uuid === kbId,
);
if (!currentKb) return null;
@@ -1078,17 +1281,17 @@ export default function DynamicFormItemComponent({
return (
<div
key={kbId}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
className="flex min-w-0 items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex items-center gap-2 flex-1">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2 font-medium">
{currentKb.emoji && (
<span className="text-sm shrink-0">
{currentKb.emoji}
</span>
)}
{currentKb.name}
<span className="truncate">{currentKb.name}</span>
{currentKb.knowledge_engine?.name && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
{extractI18nObject(
@@ -1098,7 +1301,7 @@ export default function DynamicFormItemComponent({
)}
</div>
{currentKb.description && (
<div className="text-sm text-muted-foreground">
<div className="text-sm break-words text-muted-foreground">
{currentKb.description}
</div>
)}
@@ -1156,15 +1359,13 @@ export default function DynamicFormItemComponent({
{engineName}
</div>
{kbs.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
const isSelected = tempSelectedKBIds.includes(base.uuid);
return (
<div
key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
const kbId = base.uuid ?? '';
const kbId = base.uuid;
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
@@ -1221,13 +1422,13 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.BOT_SELECTOR:
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('bots.selectBot')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{bots.map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid ?? ''}>
{bots.filter(hasUsableUuid).map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid}>
{bot.name}
</SelectItem>
))}
@@ -1239,9 +1440,9 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.TOOLS_SELECTOR:
return (
<>
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{field.value && field.value.length > 0 ? (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{field.value.map((toolName: string) => {
const currentTool = tools.find(
(tool) => tool.name === toolName,
@@ -1250,12 +1451,12 @@ export default function DynamicFormItemComponent({
return (
<div
key={toolName}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
className="flex min-w-0 items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex items-center gap-2 flex-1">
<div className="flex min-w-0 flex-1 items-center gap-2">
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium">{toolName}</div>
<div className="truncate font-medium">{toolName}</div>
{currentTool?.human_desc && (
<div className="text-sm text-muted-foreground truncate">
{currentTool.human_desc}
@@ -1369,6 +1570,32 @@ export default function DynamicFormItemComponent({
</>
);
case DynamicFormItemType.RICH_TOOLS_SELECTOR:
return (
<ToolResourceSelectors
mode="tools"
pipelineId={systemContext?.pipeline_id as string | undefined}
value={{
...(formValues || {}),
[field.name]: field.value,
}}
onChange={handleCompositePatch}
/>
);
case DynamicFormItemType.RESOURCES_SELECTOR:
return (
<ToolResourceSelectors
mode="resources"
pipelineId={systemContext?.pipeline_id as string | undefined}
value={{
...(formValues || {}),
[field.name]: field.value,
}}
onChange={handleCompositePatch}
/>
);
case DynamicFormItemType.PROMPT_EDITOR: {
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default
@@ -1379,13 +1606,16 @@ export default function DynamicFormItemComponent({
? field.value
: [{ role: 'system', content: '' }];
return (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{promptItems.map(
(item: { role: string; content: string }, index: number) => (
<div key={index} className="flex gap-2 items-center">
<div
key={index}
className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center"
>
{/* 角色选择 */}
{index === 0 ? (
<div className="w-[120px] px-3 py-2 border rounded bg-gray-50 dark:bg-[#2a292e] text-gray-500 dark:text-white dark:border-gray-600">
<div className="w-full shrink-0 rounded border bg-gray-50 px-3 py-2 text-gray-500 sm:w-[120px] dark:border-gray-600 dark:bg-[#2a292e] dark:text-white">
system
</div>
) : (
@@ -1410,7 +1640,7 @@ export default function DynamicFormItemComponent({
)}
{/* 内容输入 */}
<Textarea
className="w-[300px]"
className="min-h-20 w-full min-w-0 flex-1 resize-y overflow-x-hidden break-all sm:w-[300px]"
value={item.content}
onChange={(e) => {
const newValue = [...(field.value ?? promptItems)];
@@ -1428,20 +1658,12 @@ export default function DynamicFormItemComponent({
className="p-2 hover:bg-gray-100 rounded"
onClick={() => {
const newValue = (field.value ?? promptItems).filter(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_: any, i: number) => i !== index,
);
field.onChange(newValue);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
<Trash2 className="w-5 h-5 text-red-500" />
</button>
)}
</div>
@@ -1492,14 +1714,7 @@ export default function DynamicFormItemComponent({
}}
title={t('common.delete')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 text-destructive"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</CardContent>
</Card>
@@ -1531,14 +1746,7 @@ export default function DynamicFormItemComponent({
document.getElementById(`file-input-${config.name}`)?.click()
}
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
<Plus className="w-4 h-4 mr-2" />
{uploading
? t('plugins.fileUpload.uploading')
: t('plugins.fileUpload.chooseFile')}
@@ -1584,14 +1792,7 @@ export default function DynamicFormItemComponent({
}}
title={t('common.delete')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 text-destructive"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</CardContent>
</Card>
@@ -1626,14 +1827,7 @@ export default function DynamicFormItemComponent({
?.click()
}
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
<Plus className="w-4 h-4 mr-2" />
{uploading
? t('plugins.fileUpload.uploading')
: t('plugins.fileUpload.addFile')}
@@ -3,6 +3,7 @@ import {
DynamicFormItemType,
IDynamicFormItemOption,
IShowIfCondition,
SYSTEM_FIELD_PREFIX,
} from '@/app/infra/entities/form/dynamic';
import { I18nObject } from '@/app/infra/entities/common';
@@ -54,14 +55,25 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
export function getDefaultValues(
itemConfigList: IDynamicFormItemSchema[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Record<string, any> {
return itemConfigList.reduce(
(acc, item) => {
// `__system.*` fields are display-only (resolved from systemContext);
// their placeholder defaults must not leak into the config values.
if (item.name.startsWith(SYSTEM_FIELD_PREFIX)) {
return acc;
}
acc[item.name] = item.default;
if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) {
acc['enable-all-tools'] = true;
}
if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) {
acc['mcp-resources'] = [];
acc['mcp-resource-agent-read-enabled'] = true;
}
return acc;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{} as Record<string, any>,
);
}
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -102,9 +102,30 @@ export default function N8nAuthFormComponent({
}, {} as FormValues),
});
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
// Stable ref for onSubmit to avoid re-triggering the effect when the
// parent passes a new closure on every render (matches DynamicFormComponent pattern).
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
// 当 initialValues 变化时更新表单值
useEffect(() => {
if (initialValues) {
// Skip the first mount — defaultValues already handles it
if (isInitialMount.current) {
isInitialMount.current = false;
previousInitialValues.current = initialValues;
return;
}
// Deep compare to avoid reacting to parent re-renders that pass
// the same values back (e.g. after our own onSubmit emission).
const hasRealChange =
JSON.stringify(previousInitialValues.current) !==
JSON.stringify(initialValues);
if (initialValues && hasRealChange) {
// 合并默认值和初始值
const mergedValues = itemConfigList.reduce(
(acc, item) => {
@@ -120,11 +141,28 @@ export default function N8nAuthFormComponent({
// 更新认证类型
setAuthType((mergedValues['auth-type'] as string) || 'none');
previousInitialValues.current = initialValues;
}
}, [initialValues, form, itemConfigList]);
// 监听表单值变化
useEffect(() => {
// Emit initial form values on mount so the parent form's
// initializedStagesRef registers this stage (matches DynamicFormComponent).
const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, string>,
);
onSubmitRef.current?.(initialFinalValues);
previousInitialValues.current = initialFinalValues as Record<
string,
string
>;
const subscription = form.watch((value, { name }) => {
// 如果认证类型变化,更新状态
if (name === 'auth-type') {
@@ -141,10 +179,11 @@ export default function N8nAuthFormComponent({
{} as Record<string, string>,
);
onSubmit?.(finalValues);
onSubmitRef.current?.(finalValues);
previousInitialValues.current = finalValues as Record<string, string>;
});
return () => subscription.unsubscribe();
}, [form, onSubmit, itemConfigList]);
}, [form, itemConfigList]);
// 根据认证类型过滤表单项
const filteredConfigList = itemConfigList.filter((config) => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,206 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ImagePlus, Loader2, Paperclip, Send, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { httpClient } from '@/app/infra/http/HttpClient';
const MAX_ATTACHMENTS = 3;
const MAX_IMAGE_BYTES = 1024 * 1024;
type FeedbackAttachment = {
name: string;
mime_type: string;
data_url: string;
};
function readImageFile(file: File): Promise<FeedbackAttachment> {
return new Promise((resolve, reject) => {
if (!file.type.startsWith('image/')) {
reject(new Error('not_image'));
return;
}
if (file.size > MAX_IMAGE_BYTES) {
reject(new Error('too_large'));
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = String(reader.result || '');
if (!dataUrl.startsWith('data:image/')) {
reject(new Error('not_image'));
return;
}
resolve({
name: file.name || 'pasted-image.png',
mime_type: file.type || 'image/png',
data_url: dataUrl,
});
};
reader.onerror = () => reject(reader.error || new Error('read_failed'));
reader.readAsDataURL(file);
});
}
const FEEDBACK_I18N_PREFIX = 'monitoring.feedback';
export function FeedbackPopoverContent({
onSubmitted,
}: {
onSubmitted?: () => void;
}) {
const { t } = useTranslation();
const tf = useCallback(
(key: string) => t(`${FEEDBACK_I18N_PREFIX}.${key}`),
[t],
);
const [content, setContent] = useState('');
const [attachments, setAttachments] = useState<FeedbackAttachment[]>([]);
const [submitting, setSubmitting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const addFiles = useCallback(
async (files: File[]) => {
const slots = MAX_ATTACHMENTS - attachments.length;
if (slots <= 0) {
toast.error(tf('tooManyImages'));
return;
}
const picked = files.slice(0, slots);
const next: FeedbackAttachment[] = [];
for (const file of picked) {
try {
next.push(await readImageFile(file));
} catch (error) {
const msg = error instanceof Error ? error.message : '';
toast.error(
msg === 'too_large' ? tf('imageTooLarge') : tf('imageOnly'),
);
}
}
if (next.length > 0) {
setAttachments((prev) => [...prev, ...next].slice(0, MAX_ATTACHMENTS));
}
},
[attachments.length, tf],
);
useEffect(() => {
const onPaste = (event: ClipboardEvent) => {
const files = Array.from(event.clipboardData?.files || []).filter(
(file) => file.type.startsWith('image/'),
);
if (files.length > 0) {
event.preventDefault();
void addFiles(files);
}
};
window.addEventListener('paste', onPaste);
return () => window.removeEventListener('paste', onPaste);
}, [addFiles]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed) {
toast.error(tf('contentRequired'));
return;
}
try {
setSubmitting(true);
await httpClient.submitFeedback({
content: trimmed,
attachments,
});
toast.success(tf('submitSuccess'));
setContent('');
setAttachments([]);
onSubmitted?.();
} catch {
toast.error(tf('submitFailed'));
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-3" onClick={(e) => e.stopPropagation()}>
<div>
<div className="text-sm font-medium">{tf('title')}</div>
<p className="mt-1 text-xs text-muted-foreground">
{tf('description')}
</p>
</div>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={tf('placeholder')}
maxLength={5000}
className="min-h-32 resize-none text-sm"
/>
<div className="flex flex-wrap gap-2">
{attachments.map((item, index) => (
<div
key={`${item.name}-${index}`}
className="relative size-16 overflow-hidden rounded-md border"
>
<img
src={item.data_url}
alt={item.name}
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() =>
setAttachments((prev) => prev.filter((_, i) => i !== index))
}
className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white"
aria-label={tf('removeImage')}
>
<X className="size-3" />
</button>
</div>
))}
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
void addFiles(Array.from(e.target.files || []));
e.target.value = '';
}}
/>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="mr-1 size-4" />
{tf('attachImage')}
</Button>
</div>
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Paperclip className="size-3" />
{attachments.length}/{MAX_ATTACHMENTS}
</span>
</div>
<Button className="w-full" onClick={handleSubmit} disabled={submitting}>
{submitting ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Send className="mr-2 size-4" />
)}
{tf('submit')}
</Button>
<p className="text-[11px] leading-relaxed text-muted-foreground">
{tf('privacyHint')}
</p>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -26,11 +26,10 @@ export interface SidebarEntityItem {
installInfo?: Record<string, unknown>;
hasUpdate?: boolean;
debug?: boolean;
// Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill';
}
// Install action types that can be triggered from sidebar
export type PluginInstallAction = 'local' | 'github' | null;
// Plugin page registered by a plugin
export interface PluginPageItem {
id: string; // "author/name/pageId"
@@ -50,19 +49,21 @@ export interface SidebarDataContextValue {
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
mcpServers: SidebarEntityItem[];
skills: SidebarEntityItem[];
pluginPages: PluginPageItem[];
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
refreshPlugins: () => Promise<void>;
refreshMCPServers: () => Promise<void>;
refreshSkills: () => Promise<void>;
refreshAll: () => Promise<void>;
// Breadcrumb: entity name shown when viewing a detail page
detailEntityName: string | null;
setDetailEntityName: (name: string | null) => void;
// Pending plugin install action triggered from sidebar
pendingPluginInstallAction: PluginInstallAction;
setPendingPluginInstallAction: (action: PluginInstallAction) => void;
// Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void;
}
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
@@ -77,10 +78,22 @@ export function SidebarDataProvider({
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
useState<PluginInstallAction>(null);
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('extensions_group_by_type') === 'true';
});
const setExtensionsGroupByType = useCallback((enabled: boolean) => {
setExtensionsGroupByTypeState(enabled);
try {
localStorage.setItem('extensions_group_by_type', String(enabled));
} catch {
// ignore
}
}, []);
const refreshBots = useCallback(async () => {
try {
@@ -224,8 +237,8 @@ export function SidebarDataProvider({
const resp = await httpClient.getMCPServers();
setMCPServers(
resp.servers.map((server) => ({
id: server.name,
name: server.name,
id: server.name, // Keep __ for API calls
name: server.name.replace(/__/g, '/'), // Display with / for readability
enabled: server.enable,
runtimeStatus: server.runtime_info?.status,
})),
@@ -235,6 +248,22 @@ export function SidebarDataProvider({
}
}, []);
const refreshSkills = useCallback(async () => {
try {
const resp = await httpClient.getSkills();
setSkills(
resp.skills.map((skill) => ({
id: skill.name,
name: skill.display_name || skill.name,
description: skill.description,
updatedAt: skill.updated_at,
})),
);
} catch (error) {
console.error('Failed to fetch skills for sidebar:', error);
}
}, []);
const refreshAll = useCallback(async () => {
await Promise.all([
refreshBots(),
@@ -242,6 +271,7 @@ export function SidebarDataProvider({
refreshKnowledgeBases(),
refreshPlugins(),
refreshMCPServers(),
refreshSkills(),
]);
}, [
refreshBots,
@@ -249,6 +279,7 @@ export function SidebarDataProvider({
refreshKnowledgeBases,
refreshPlugins,
refreshMCPServers,
refreshSkills,
]);
// Fetch all entity lists on mount
@@ -264,17 +295,19 @@ export function SidebarDataProvider({
knowledgeBases,
plugins,
mcpServers,
skills,
pluginPages,
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
refreshPlugins,
refreshMCPServers,
refreshSkills,
refreshAll,
detailEntityName,
setDetailEntityName,
pendingPluginInstallAction,
setPendingPluginInstallAction,
extensionsGroupByType,
setExtensionsGroupByType,
}}
>
{children}
@@ -1,5 +1,14 @@
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import i18n from '@/i18n';
import {
Zap,
LayoutDashboard,
Bot,
Workflow,
BookMarked,
Puzzle,
PlusCircle,
} from 'lucide-react';
const t = (key: string) => {
return i18n.t(key);
@@ -10,16 +19,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'wizard',
name: t('sidebar.quickStart'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M13 9H21L11 24V15H4L13 0V9ZM11 11V7.22063L7.53238 13H13V17.3944L17.263 11H11Z"></path>
</svg>
),
icon: <Zap className="text-blue-500" />,
route: '/wizard',
description: t('wizard.sidebarDescription'),
helpLink: {
@@ -33,16 +33,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'monitoring',
name: t('monitoring.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z"></path>
</svg>
),
icon: <LayoutDashboard className="text-blue-500" />,
route: '/home/monitoring',
description: t('monitoring.description'),
helpLink: {
@@ -54,16 +45,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'bots',
name: t('bots.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z"></path>
</svg>
),
icon: <Bot className="text-blue-500" />,
route: '/home/bots',
description: t('bots.description'),
helpLink: {
@@ -76,16 +58,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'pipelines',
name: t('pipelines.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path>
</svg>
),
icon: <Workflow className="text-blue-500" />,
route: '/home/pipelines',
description: t('pipelines.description'),
helpLink: {
@@ -98,16 +71,7 @@ export const sidebarConfigList = [
new SidebarChildVO({
id: 'knowledge',
name: t('knowledge.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z"></path>
</svg>
),
icon: <BookMarked className="text-blue-500" />,
route: '/home/knowledge',
description: t('knowledge.description'),
helpLink: {
@@ -117,70 +81,30 @@ export const sidebarConfigList = [
},
section: 'home',
}),
// ── Extensions section ──
new SidebarChildVO({
id: 'plugins',
name: t('sidebar.installedPlugins'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
</svg>
),
route: '/home/plugins',
icon: <Puzzle className="text-blue-500" />,
route: '/home/extensions',
description: t('plugins.description'),
helpLink: {
en_US: 'https://link.langbot.app/en/docs/plugins',
zh_Hans: 'https://link.langbot.app/zh/docs/plugins',
ja_JP: 'https://link.langbot.app/ja/docs/plugins',
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
},
section: 'extensions',
}),
new SidebarChildVO({
id: 'market',
name: t('sidebar.pluginMarket'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M21 13.242V20H22V22H2V20H3V13.242C1.79401 12.435 1 11.0602 1 9.5C1 8.67286 1.25027 7.90335 1.67755 7.2612L4.5547 2.36088C4.80513 1.93859 5.26028 1.67578 5.76 1.67578H18.24C18.7397 1.67578 19.1949 1.93859 19.4453 2.36088L22.3225 7.2612C22.7497 7.90335 23 8.67286 23 9.5C23 11.0602 22.206 12.435 21 13.242ZM19 13.972C18.4511 14.0706 17.8794 14.0706 17.3305 13.972C16.1644 13.7566 15.1377 13.0712 14.5 12.1C13.8623 13.0712 12.8356 13.7566 11.6695 13.972C11.1206 14.0706 10.5489 14.0706 10 13.972C9.45108 14.0706 8.87938 14.0706 8.33053 13.972C7.16437 13.7566 6.13771 13.0712 5.5 12.1C4.86229 13.0712 3.83563 13.7566 2.66947 13.972C2.44883 14.0124 2.22434 14.0352 2 14.0404V20H5V15H10V20H19V13.972Z"></path>
</svg>
),
route: '/home/market',
id: 'add-extension',
name: t('sidebar.addExtension'),
icon: <PlusCircle className="text-blue-500" />,
route: '/home/add-extension',
description: t('plugins.description'),
helpLink: {
en_US: 'https://link.langbot.app/en/docs/plugins',
zh_Hans: 'https://link.langbot.app/zh/docs/plugins',
ja_JP: 'https://link.langbot.app/ja/docs/plugins',
},
section: 'extensions',
}),
new SidebarChildVO({
id: 'mcp',
name: t('sidebar.mcpServers'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="text-blue-500"
>
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
</svg>
),
route: '/home/mcp',
description: t('mcp.title'),
helpLink: {
en_US: '',
zh_Hans: '',
en_US: 'https://docs.langbot.app/en/plugin/plugin-intro',
zh_Hans: 'https://docs.langbot.app/zh/plugin/plugin-intro',
ja_JP: 'https://docs.langbot.app/ja/plugin/plugin-intro',
},
section: 'extensions',
}),
@@ -23,10 +23,13 @@ import {
LANGBOT_MODELS_PROVIDER_REQUESTER,
} from './types';
import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
interface ModelsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
// Notify parent when a nested modal (provider form) should block outer-close.
onBlockingChange?: (blocking: boolean) => void;
}
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
@@ -64,10 +67,21 @@ function convertExtraArgsToObject(
return obj;
}
export default function ModelsDialog({
open,
onOpenChange,
}: ModelsDialogProps) {
function parseContextLength(
value: number | null | undefined,
invalidMessage: string,
): number | null {
if (value === undefined || value === null) return null;
if (!Number.isInteger(value) || value <= 0) {
throw new Error(invalidMessage);
}
return value;
}
export default function ModelsPanel({
active,
onBlockingChange,
}: ModelsPanelProps) {
const { t } = useTranslation();
const [providers, setProviders] = useState<ModelProvider[]>([]);
@@ -91,6 +105,12 @@ export default function ModelsDialog({
null,
);
// Map of requester name -> support_type[] (from requester manifests),
// used to restrict which model-type tabs are shown when adding models.
const [requesterSupportTypes, setRequesterSupportTypes] = useState<
Record<string, string[]>
>({});
// Popover states
const [addModelPopoverOpen, setAddModelPopoverOpen] = useState<string | null>(
null,
@@ -119,11 +139,17 @@ export default function ModelsDialog({
);
useEffect(() => {
if (open) {
if (active) {
loadUserInfo();
loadProviders();
loadRequesterSupportTypes();
}
}, [open]);
}, [active]);
// Notify parent of blocking state so it can guard outer-close.
useEffect(() => {
onBlockingChange?.(providerFormOpen);
}, [providerFormOpen, onBlockingChange]);
// Auto-expand LangBot Models when no external providers exist
useEffect(() => {
@@ -161,6 +187,19 @@ export default function ModelsDialog({
}
}
async function loadRequesterSupportTypes() {
try {
const resp = await httpClient.getProviderRequesters();
const map: Record<string, string[]> = {};
for (const r of resp.requesters) {
map[r.name] = r.spec?.support_type ?? [];
}
setRequesterSupportTypes(map);
} catch (err) {
console.error('Failed to load requester support types', err);
}
}
async function loadProviderModels(providerUuid: string, silent = false) {
if (loadingProviders.has(providerUuid)) return;
@@ -254,6 +293,7 @@ export default function ModelsDialog({
name: string,
abilities: string[],
extraArgs: ExtraArg[],
contextLength?: number | null,
) {
if (!name.trim()) {
toast.error(t('models.modelNameRequired'));
@@ -268,6 +308,10 @@ export default function ModelsDialog({
name,
provider_uuid: providerUuid,
abilities,
context_length: parseContextLength(
contextLength,
t('models.contextLengthInvalid'),
),
extra_args: extraArgsObj,
} as never);
} else if (modelType === 'embedding') {
@@ -325,6 +369,7 @@ export default function ModelsDialog({
name: item.model.name,
provider_uuid: providerUuid,
abilities: item.abilities,
context_length: item.model.context_length ?? null,
extra_args: {},
} as never);
} else if (effectiveType === 'embedding') {
@@ -361,6 +406,7 @@ export default function ModelsDialog({
name: string,
abilities: string[],
extraArgs: ExtraArg[],
contextLength?: number | null,
) {
if (!name.trim()) {
toast.error(t('models.modelNameRequired'));
@@ -375,6 +421,10 @@ export default function ModelsDialog({
name,
provider_uuid: providerUuid,
abilities,
context_length: parseContextLength(
contextLength,
t('models.contextLengthInvalid'),
),
extra_args: extraArgsObj,
} as never);
} else if (modelType === 'embedding') {
@@ -495,6 +545,7 @@ export default function ModelsDialog({
key={provider.uuid}
provider={provider}
isLangBotModels={isLangBotModels}
supportTypes={requesterSupportTypes[provider.requester]}
isExpanded={expandedProviders.has(provider.uuid)}
isLoading={loadingProviders.has(provider.uuid)}
models={providerModels[provider.uuid]}
@@ -509,8 +560,15 @@ export default function ModelsDialog({
onSpaceLogin={handleSpaceLogin}
onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}
onCloseAddModel={() => setAddModelPopoverOpen(null)}
onAddModel={(modelType, name, abilities, extraArgs) =>
handleAddModel(provider.uuid, modelType, name, abilities, extraArgs)
onAddModel={(modelType, name, abilities, extraArgs, contextLength) =>
handleAddModel(
provider.uuid,
modelType,
name,
abilities,
extraArgs,
contextLength,
)
}
onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)}
onAddScannedModels={(modelType, models) =>
@@ -518,7 +576,14 @@ export default function ModelsDialog({
}
onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)}
onCloseEditModel={() => setEditModelPopoverOpen(null)}
onUpdateModel={(modelId, modelType, name, abilities, extraArgs) =>
onUpdateModel={(
modelId,
modelType,
name,
abilities,
extraArgs,
contextLength,
) =>
handleUpdateModel(
provider.uuid,
modelId,
@@ -526,6 +591,7 @@ export default function ModelsDialog({
name,
abilities,
extraArgs,
contextLength,
)
}
onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)}
@@ -546,60 +612,41 @@ export default function ModelsDialog({
return (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && providerFormOpen) return;
onOpenChange(newOpen);
}}
>
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
<DialogTitle>{t('models.title')}</DialogTitle>
</DialogHeader>
<PanelBody>
{/* LangBot Models (Space) provider card is intentionally pinned to the
top, above the "add custom provider" action row. */}
{langbotProvider && renderProviderCard(langbotProvider, true)}
<div className="flex-1 overflow-auto px-6 pb-6 mt-0">
{/* LangBot Models Card */}
{langbotProvider && renderProviderCard(langbotProvider, true)}
{/* Add-provider row: stays below the pinned card by design. */}
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
{otherProviders.length === 0
? t(
systemInfo.disable_models_service
? 'models.addProviderHintSimple'
: 'models.addProviderHint',
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
{/* Add Provider Button */}
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
<span className="text-sm text-muted-foreground">
{otherProviders.length === 0
? t(
systemInfo.disable_models_service
? 'models.addProviderHintSimple'
: 'models.addProviderHint',
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleCreateProvider}
>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
</div>
{/* Provider List */}
{otherProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{t('models.noProviders')}</p>
</div>
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
{/* Provider List */}
{otherProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{t('models.noProviders')}</p>
</div>
</DialogContent>
</Dialog>
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
</PanelBody>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-[600px] p-6">
<DialogContent className="w-full max-w-[calc(100%-2rem)] p-4 sm:max-w-[600px] sm:p-6">
<DialogHeader>
<DialogTitle>
{editingProviderId
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -16,19 +16,12 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DialogFooter } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common';
import { cn } from '@/lib/utils';
import { Check, ChevronDown, Search } from 'lucide-react';
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -61,6 +54,7 @@ export default function ProviderForm({
api_key: '',
},
});
const { setValue } = form;
const [requesterList, setRequesterList] = useState<
{
@@ -69,20 +63,15 @@ export default function ProviderForm({
category: string;
defaultUrl: string;
description: string;
alias: string;
}[]
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
async function init() {
await loadRequesters();
if (providerId) {
await loadProvider(providerId);
}
}
init();
}, [providerId]);
async function loadRequesters() {
const loadRequesters = useCallback(async () => {
const resp = await httpClient.getProviderRequesters();
setRequesterList(
resp.requesters
@@ -96,19 +85,82 @@ export default function ProviderForm({
.find((c) => c.name === 'base_url')
?.default?.toString() || '',
description: extractI18nObject(item.description),
alias: item.spec.alias || '',
})),
);
}
}, []);
async function loadProvider(id: string) {
const resp = await httpClient.getModelProvider(id);
const provider = resp.provider;
const loadProvider = useCallback(
async (id: string) => {
const resp = await httpClient.getModelProvider(id);
const provider = resp.provider;
form.setValue('name', provider.name);
form.setValue('requester', provider.requester);
form.setValue('base_url', provider.base_url);
form.setValue('api_key', provider.api_keys?.[0] || '');
}
setValue('name', provider.name);
setValue('requester', provider.requester);
setValue('base_url', provider.base_url);
setValue('api_key', provider.api_keys?.[0] || '');
},
[setValue],
);
useEffect(() => {
async function init() {
await loadRequesters();
if (providerId) {
await loadProvider(providerId);
}
}
init();
}, [providerId, loadProvider, loadRequesters]);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Focus search input when dropdown opens
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
// Filter requesters based on search query
const filteredRequesters = requesterList.filter(
(r) =>
r.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.value.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.alias.toLowerCase().includes(searchQuery.toLowerCase()),
);
// Group filtered requesters by category
const groupedRequesters = {
builtin: filteredRequesters.filter((r) => r.category === 'builtin'),
manufacturer: filteredRequesters.filter(
(r) => r.category === 'manufacturer',
),
maas: filteredRequesters.filter((r) => r.category === 'maas'),
'self-hosted': filteredRequesters.filter(
(r) => r.category === 'self-hosted',
),
};
const categoryLabels: Record<string, string> = {
builtin: t('models.builtin'),
manufacturer: t('models.modelManufacturer'),
maas: t('models.aggregationPlatform'),
'self-hosted': t('models.selfDeployed'),
};
async function handleFormSubmit(values: z.infer<typeof formSchema>) {
const data = {
@@ -168,17 +220,16 @@ export default function ProviderForm({
{t('models.requester')}
<span className="text-red-500">*</span>
</FormLabel>
<Select
onValueChange={(v) => {
field.onChange(v);
const req = requesterList.find((r) => r.value === v);
if (req && (!providerId || !form.getValues('base_url'))) {
form.setValue('base_url', req.defaultUrl);
}
}}
value={field.value}
>
<SelectTrigger className="bg-background">
<div ref={dropdownRef} className="relative">
{/* Trigger button */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'ring-2 ring-ring ring-offset-2',
)}
>
{selectedRequester ? (
<div className="flex items-center gap-2">
<img
@@ -191,90 +242,102 @@ export default function ProviderForm({
<span>{selectedRequester.label}</span>
</div>
) : (
<SelectValue placeholder={t('models.selectRequester')} />
<span className="text-muted-foreground">
{t('models.selectRequester')}
</span>
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t('models.builtin')}</SelectLabel>
{requesterList
.filter((r) => r.category === 'builtin')
.map((r) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span>{r.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{t('models.modelManufacturer')}</SelectLabel>
{requesterList
.filter((r) => r.category === 'manufacturer')
.map((r) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span>{r.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.aggregationPlatform')}
</SelectLabel>
{requesterList
.filter((r) => r.category === 'maas')
.map((r) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span>{r.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{t('models.selfDeployed')}</SelectLabel>
{requesterList
.filter((r) => r.category === 'self-hosted')
.map((r) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span>{r.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<ChevronDown
className={cn(
'h-4 w-4 opacity-50 transition-transform',
isOpen && 'rotate-180',
)}
/>
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
{/* Search input */}
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
ref={searchInputRef}
type="text"
placeholder={
t('models.searchProviders') || 'Search providers...'
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
{/* Options list */}
<div className="max-h-[300px] overflow-y-auto p-1">
{Object.entries(groupedRequesters).map(
([category, items]) => {
if (items.length === 0) return null;
return (
<div key={category}>
<div className="py-1.5 px-2 text-xs font-semibold text-muted-foreground">
{categoryLabels[category]}
</div>
{items.map((r) => (
<button
key={r.value}
type="button"
onClick={() => {
field.onChange(r.value);
const req = requesterList.find(
(req) => req.value === r.value,
);
if (
req &&
(!providerId ||
!form.getValues('base_url'))
) {
form.setValue(
'base_url',
req.defaultUrl,
);
}
setIsOpen(false);
setSearchQuery('');
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground cursor-pointer',
field.value === r.value &&
'bg-accent text-accent-foreground',
)}
>
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span className="flex-1 text-left">
{r.label}
</span>
{field.value === r.value && (
<Check className="h-4 w-4" />
)}
</button>
))}
</div>
);
},
)}
{filteredRequesters.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
No results found.
</div>
)}
</div>
</div>
)}
</div>
<FormMessage />
{selectedRequester?.description && (
<p className="text-sm text-muted-foreground">
@@ -18,7 +18,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import { ScannedProviderModel } from '@/app/infra/entities/api';
import {
@@ -34,6 +34,7 @@ interface AddModelPopoverProps {
isOpen: boolean;
initialMode?: 'manual' | 'scan';
trigger?: React.ReactNode;
supportTypes?: string[];
onOpen: () => void;
onClose: () => void;
onAddModel: (
@@ -41,6 +42,7 @@ interface AddModelPopoverProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
@@ -63,6 +65,7 @@ export default function AddModelPopover({
isOpen,
initialMode = 'manual',
trigger,
supportTypes,
onOpen,
onClose,
onAddModel,
@@ -77,10 +80,26 @@ export default function AddModelPopover({
const { t } = useTranslation();
const prevIsOpenRef = useRef(false);
const [tab, setTab] = useState<ModelType>('llm');
// Map manifest support_type values to UI tab values.
// Manifest uses 'text-embedding'; the UI tab uses 'embedding'.
const tabSupport: Record<ModelType, string> = {
llm: 'llm',
embedding: 'text-embedding',
rerank: 'rerank',
};
const allTabs: ModelType[] = ['llm', 'embedding', 'rerank'];
// When supportTypes is undefined (unknown requester), show all tabs for
// backward compatibility. Otherwise only show supported tabs.
const visibleTabs: ModelType[] = supportTypes
? allTabs.filter((tabKey) => supportTypes.includes(tabSupport[tabKey]))
: allTabs;
const defaultTab: ModelType = visibleTabs[0] ?? 'llm';
const [tab, setTab] = useState<ModelType>(defaultTab);
const [mode, setMode] = useState<'manual' | 'scan'>('manual');
const [name, setName] = useState('');
const [abilities, setAbilities] = useState<string[]>([]);
const [contextLength, setContextLength] = useState('');
const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]);
const [scanLoading, setScanLoading] = useState(false);
const [scannedModels, setScannedModels] = useState<ScannedProviderModel[]>(
@@ -94,10 +113,11 @@ export default function AddModelPopover({
useEffect(() => {
const wasOpen = prevIsOpenRef.current;
if (isOpen && !wasOpen) {
setTab('llm');
setTab(defaultTab);
setMode(initialMode);
setName('');
setAbilities([]);
setContextLength('');
setExtraArgs([]);
setScanLoading(false);
setScannedModels([]);
@@ -119,7 +139,11 @@ export default function AddModelPopover({
}, [tab, mode]);
const handleAdd = async () => {
await onAddModel(tab, name, abilities, extraArgs);
const parsedContextLength =
tab === 'llm' && contextLength.trim()
? Number(contextLength.trim())
: null;
await onAddModel(tab, name, abilities, extraArgs, parsedContextLength);
};
const handleTest = async () => {
@@ -130,32 +154,6 @@ export default function AddModelPopover({
setScanLoading(true);
try {
const result = await onScanModels(trigger ? undefined : tab);
const debugData = (
result.debug?.response as { data?: Record<string, unknown>[] }
)?.data;
if (Array.isArray(debugData)) {
const debugMap = new Map<string, Record<string, unknown>>();
for (const item of debugData) {
if (typeof item?.id === 'string') {
debugMap.set(item.id, item);
}
}
for (const model of result.models) {
const debugItem = debugMap.get(model.id);
if (!debugItem) continue;
const features = debugItem.features as
| Record<string, unknown>
| undefined;
const tools = features?.tools as Record<string, unknown> | undefined;
if (tools?.function_calling === true) {
const nextAbilities = new Set(model.abilities || []);
nextAbilities.add('func_call');
model.abilities = [...nextAbilities];
}
}
}
setScannedModels(result.models);
setSelectedScannedModels({});
} finally {
@@ -279,39 +277,38 @@ export default function AddModelPopover({
className="flex flex-col min-h-0 flex-1"
>
<div className="flex-shrink-0">
{!(trigger && initialMode === 'scan') && (
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="llm">
<MessageSquareText className="h-4 w-4 mr-1" />
{t('models.chat')}
</TabsTrigger>
<TabsTrigger value="embedding">
<Cpu className="h-4 w-4 mr-1" />
{t('models.embedding')}
</TabsTrigger>
<TabsTrigger value="rerank">
<ArrowUpDown className="h-4 w-4 mr-1" />
{t('models.rerank')}
</TabsTrigger>
{!(trigger && initialMode === 'scan') && visibleTabs.length > 1 && (
<TabsList
className="grid w-full"
style={{
gridTemplateColumns: `repeat(${visibleTabs.length}, minmax(0, 1fr))`,
}}
>
{visibleTabs.includes('llm') && (
<TabsTrigger value="llm">
<MessageSquareText className="h-4 w-4 mr-1" />
{t('models.chat')}
</TabsTrigger>
)}
{visibleTabs.includes('embedding') && (
<TabsTrigger value="embedding">
<Cpu className="h-4 w-4 mr-1" />
{t('models.embedding')}
</TabsTrigger>
)}
{visibleTabs.includes('rerank') && (
<TabsTrigger value="rerank">
<ArrowUpDown className="h-4 w-4 mr-1" />
{t('models.rerank')}
</TabsTrigger>
)}
</TabsList>
)}
</div>
<div className="overflow-y-auto flex-1 min-h-0">
<Tabs
value={mode}
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
>
{!trigger && (
<TabsList className="grid w-full grid-cols-2 mt-3">
<TabsTrigger value="manual">
{t('models.manualAdd')}
</TabsTrigger>
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
</TabsList>
)}
<TabsContent value="manual" className="mt-3">
{mode === 'manual' ? (
<div className="mt-3">
<div className="space-y-3">
<div className="space-y-2">
<Label>{t('models.modelName')}</Label>
@@ -356,6 +353,24 @@ export default function AddModelPopover({
</div>
)}
{tab === 'llm' && (
<div className="space-y-2">
<Label htmlFor="add-context-length">
{t('models.contextLength')}
</Label>
<Input
id="add-context-length"
type="number"
min={1}
step={1}
inputMode="numeric"
placeholder={t('models.contextLengthPlaceholder')}
value={contextLength}
onChange={(e) => setContextLength(e.target.value)}
/>
</div>
)}
<ExtraArgsEditor
args={extraArgs}
onChange={setExtraArgs}
@@ -390,9 +405,9 @@ export default function AddModelPopover({
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="scan" className="space-y-2 mt-0 pt-0">
</div>
) : (
<div className="space-y-2 mt-3">
{scanLoading ? (
<div className="flex items-center justify-center py-4">
<RefreshCw className="h-4 w-4 mr-2 animate-spin text-muted-foreground" />
@@ -565,8 +580,8 @@ export default function AddModelPopover({
/>
</Button>
</div>
</TabsContent>
</Tabs>
</div>
)}
</div>
</Tabs>
</PopoverContent>
@@ -31,6 +31,7 @@ interface ModelItemProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>;
onTestModel: (
name: string,
@@ -92,6 +93,11 @@ export default function ModelItem({
const [editAbilities, setEditAbilities] = useState<string[]>(
modelType === 'llm' ? (model as LLMModel).abilities || [] : [],
);
const [editContextLength, setEditContextLength] = useState(
modelType === 'llm' && (model as LLMModel).context_length
? String((model as LLMModel).context_length)
: '',
);
const [editExtraArgs, setEditExtraArgs] = useState<ExtraArg[]>(
convertExtraArgsToArray(model.extra_args),
);
@@ -106,13 +112,27 @@ export default function ModelItem({
setEditAbilities(
modelType === 'llm' ? (model as LLMModel).abilities || [] : [],
);
setEditContextLength(
modelType === 'llm' && (model as LLMModel).context_length
? String((model as LLMModel).context_length)
: '',
);
setEditExtraArgs(convertExtraArgsToArray(model.extra_args));
onResetTestResult();
}
}, [isEditOpen]);
const handleSave = async () => {
await onUpdateModel(editName, editAbilities, editExtraArgs);
const parsedContextLength =
modelType === 'llm' && editContextLength.trim()
? Number(editContextLength.trim())
: null;
await onUpdateModel(
editName,
editAbilities,
editExtraArgs,
parsedContextLength,
);
};
const handleTest = async () => {
@@ -287,6 +307,25 @@ export default function ModelItem({
</div>
)}
{modelType === 'llm' && (
<div className="space-y-2">
<Label htmlFor={`edit-context-length-${model.uuid}`}>
{t('models.contextLength')}
</Label>
<Input
id={`edit-context-length-${model.uuid}`}
type="number"
min={1}
step={1}
inputMode="numeric"
placeholder={t('models.contextLengthPlaceholder')}
value={editContextLength}
disabled={isLangBotModels}
onChange={(e) => setEditContextLength(e.target.value)}
/>
</div>
)}
<ExtraArgsEditor
args={editExtraArgs}
onChange={setEditExtraArgs}
@@ -39,6 +39,7 @@ import AddModelPopover from './AddModelPopover';
interface ProviderCardProps {
provider: ModelProvider;
isLangBotModels?: boolean;
supportTypes?: string[];
isExpanded: boolean;
isLoading: boolean;
models?: ProviderModels;
@@ -60,6 +61,7 @@ interface ProviderCardProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
@@ -74,6 +76,7 @@ interface ProviderCardProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>;
onOpenDeleteConfirm: (modelId: string) => void;
onCloseDeleteConfirm: () => void;
@@ -99,6 +102,7 @@ function maskApiKey(key: string): string {
export default function ProviderCard({
provider,
isLangBotModels = false,
supportTypes,
isExpanded,
isLoading,
models,
@@ -146,9 +150,9 @@ export default function ProviderCard({
return (
<Card className="mb-2">
<Collapsible open={isExpanded} onOpenChange={onToggle}>
<CardHeader className="py-0 px-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
<CardHeader className="py-0 px-4 min-w-0 [&]:grid-cols-[minmax(0,1fr)]">
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="flex items-center gap-2 flex-1 min-w-0">
{isLangBotModels ? (
<div className="w-9 h-9 rounded-lg overflow-hidden flex-shrink-0">
<img
@@ -167,9 +171,11 @@ export default function ProviderCard({
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{provider.name}</CardTitle>
<Badge variant="outline" className="text-xs">
<div className="flex items-center gap-2 min-w-0">
<CardTitle className="text-base truncate">
{provider.name}
</CardTitle>
<Badge variant="outline" className="text-xs shrink-0">
{t('models.modelsCount', { count: totalModels })}
</Badge>
</div>
@@ -189,7 +195,7 @@ export default function ProviderCard({
</p>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<div className="flex items-center gap-1 ml-2 shrink-0">
{isLangBotModels && accountType !== 'space' && (
<Button
variant="outline"
@@ -319,6 +325,7 @@ export default function ProviderCard({
addModelMode === 'manual'
}
initialMode="manual"
supportTypes={supportTypes}
trigger={
<Button
variant="ghost"
@@ -353,6 +360,7 @@ export default function ProviderCard({
addModelMode === 'scan'
}
initialMode="scan"
supportTypes={supportTypes}
trigger={
<Button
variant="ghost"
@@ -405,13 +413,19 @@ export default function ProviderCard({
onOpenDeleteConfirm={onOpenDeleteConfirm}
onCloseDeleteConfirm={onCloseDeleteConfirm}
onDeleteModel={() => onDeleteModel(model.uuid, 'llm')}
onUpdateModel={(name, abilities, extraArgs) =>
onUpdateModel={(
name,
abilities,
extraArgs,
contextLength,
) =>
onUpdateModel(
model.uuid,
'llm',
name,
abilities,
extraArgs,
contextLength,
)
}
onTestModel={(name, abilities, extraArgs) =>
@@ -53,6 +53,7 @@ export interface ModelItemProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>;
onTest: (
name: string,
@@ -89,6 +90,7 @@ export interface ProviderCardProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
@@ -103,6 +105,7 @@ export interface ProviderCardProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
contextLength?: number | null,
) => Promise<void>;
onOpenDeleteConfirm: (modelId: string) => void;
onCloseDeleteConfirm: () => void;
@@ -0,0 +1,229 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from '@/components/ui/sidebar';
import { cn } from '@/lib/utils';
import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/AccountSettingsPanel';
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
// The set of settings sections shown in the unified dialog. The string values
// are also reused as the ?action= query param suffix so deep links keep working.
export type SettingsSection =
| 'account'
| 'apiIntegration'
| 'models'
| 'storageAnalysis';
// Map between a section id and its ?action= query value, so existing deep links
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
// showStorageAnalysis) continue to resolve to the right section.
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
account: 'showAccountSettings',
apiIntegration: 'showApiIntegrationSettings',
models: 'showModelSettings',
storageAnalysis: 'showStorageAnalysis',
};
export const SETTINGS_SECTION_BY_ACTION: Record<string, SettingsSection> =
Object.fromEntries(
Object.entries(SETTINGS_ACTION_BY_SECTION).map(([section, action]) => [
action,
section as SettingsSection,
]),
);
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
section: SettingsSection;
onSectionChange: (section: SettingsSection) => void;
}
export default function SettingsDialog({
open,
onOpenChange,
section,
onSectionChange,
}: SettingsDialogProps) {
const { t } = useTranslation();
// A nested modal (e.g. the provider form) can request that we ignore
// outer-close until it is dismissed.
const [blocking, setBlocking] = useState(false);
// Only the Models panel can raise a blocking nested modal. When we navigate
// away from it (or close the dialog) the panel unmounts without resetting,
// so clear the flag here to avoid getting stuck unable to close.
useEffect(() => {
if (section !== 'models' || !open) {
setBlocking(false);
}
}, [section, open]);
const navItems: {
id: SettingsSection;
label: string;
title: string;
description: string;
icon: React.ReactNode;
}[] = [
{
id: 'models',
label: t('settingsDialog.nav.models'),
title: t('models.title'),
description: t('models.description'),
icon: <Sparkles className="size-4" />,
},
{
id: 'apiIntegration',
label: t('settingsDialog.nav.api'),
title: t('common.apiIntegration'),
description: t('common.apiIntegrationDescription'),
icon: <KeyRound className="size-4" />,
},
{
id: 'storageAnalysis',
label: t('settingsDialog.nav.storage'),
title: t('storageAnalysis.title'),
description: t('storageAnalysis.description'),
icon: <HardDrive className="size-4" />,
},
{
id: 'account',
label: t('settingsDialog.nav.account'),
title: t('account.settings'),
description: t('account.settingsDescription'),
icon: <Settings className="size-4" />,
},
];
const activeItem = navItems.find((item) => item.id === section);
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && blocking) return;
onOpenChange(newOpen);
}}
>
<DialogContent
className="h-[80vh] max-h-[800px] overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
// Fixed height so switching sections never resizes the dialog; each
// panel scrolls its own content internally.
>
<DialogTitle className="sr-only">
{t('settingsDialog.title')}
</DialogTitle>
<DialogDescription className="sr-only">{activeLabel}</DialogDescription>
{/* Override the SidebarProvider wrapper's default h-svh so the two
columns fill the dialog's fixed height instead of the viewport. */}
<SidebarProvider className="!min-h-0 h-full">
<Sidebar
collapsible="none"
className="hidden h-full w-44 shrink-0 border-r md:flex"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<div className="px-2 py-3 text-sm font-semibold">
{t('settingsDialog.title')}
</div>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={section === item.id}
onClick={() => onSectionChange(item.id)}
>
{item.icon}
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
{/* Mobile section switcher (sidebar is hidden on small screens) */}
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
{navItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSectionChange(item.id)}
className={cn(
'flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm',
section === item.id
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground',
)}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</div>
{/* Unified section header (shared across all tabs). The extra
right padding keeps the title clear of the dialog's close X. */}
<div className="flex shrink-0 flex-col gap-0.5 border-b px-6 py-4 pr-12">
<h2 className="flex items-center gap-2 text-base font-semibold">
{activeItem?.icon}
{activeItem?.title}
</h2>
{activeItem?.description && (
<p className="text-sm text-muted-foreground">
{activeItem.description}
</p>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{section === 'models' && (
<ModelsPanel
active={open && section === 'models'}
onBlockingChange={setBlocking}
/>
)}
{section === 'apiIntegration' && (
<ApiIntegrationPanel
active={open && section === 'apiIntegration'}
/>
)}
{section === 'storageAnalysis' && (
<StorageAnalysisPanel
active={open && section === 'storageAnalysis'}
/>
)}
{section === 'account' && (
<AccountSettingsPanel active={open && section === 'account'} />
)}
</div>
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,50 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Shared layout primitives for the settings-dialog panels.
*
* Every section renders under the dialog's unified header, so the panels
* themselves should share the same vertical rhythm: an optional top toolbar
* (meta on the left, primary action on the right) followed by a scrollable
* body with consistent padding. Keeping these in one place is what makes the
* tabs feel like one cohesive surface instead of four separately-styled views.
*/
export function PanelToolbar({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
'flex shrink-0 flex-wrap items-center justify-between gap-2 border-b px-3 py-3 sm:gap-3 sm:px-6',
className,
)}
>
{children}
</div>
);
}
export function PanelBody({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
'min-h-0 flex-1 overflow-auto px-3 py-4 sm:px-6 sm:py-5',
className,
)}
>
{children}
</div>
);
}
@@ -1,410 +0,0 @@
'use client';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Archive,
Clock,
Database,
FileWarning,
HardDrive,
RefreshCw,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
interface StorageSection {
key: string;
path: string;
exists: boolean;
size_bytes: number;
file_count: number;
}
interface CleanupCandidate {
key?: string;
name?: string;
size_bytes: number;
modified_at?: string;
date?: string;
}
interface StorageAnalysis {
generated_at: string;
cleanup_policy: {
uploaded_file_retention_days: number;
log_retention_days: number;
};
sections: StorageSection[];
database: {
type: string;
monitoring_counts: Record<string, number>;
binary_storage: {
count: number;
size_bytes: number | null;
};
};
cleanup_candidates: {
uploaded_files: CleanupCandidate[];
log_files: CleanupCandidate[];
};
tasks: Record<string, number | undefined>;
}
interface StorageAnalysisDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) {
return '-';
}
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
export default function StorageAnalysisDialog({
open,
onOpenChange,
}: StorageAnalysisDialogProps) {
const { t } = useTranslation();
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await backendClient.get<StorageAnalysis>(
'/api/v1/system/storage-analysis',
);
setAnalysis(result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
loadAnalysis();
}
}, [loadAnalysis, open]);
const totalBytes = useMemo(() => {
return (
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
);
}, [analysis]);
const uploadedCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.uploaded_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
const logCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.log_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!flex h-[86vh] max-h-[86vh] max-w-5xl flex-col gap-0 p-0">
<DialogHeader className="shrink-0 px-6 pt-6">
<DialogTitle className="flex items-center gap-2">
<HardDrive className="size-5 text-blue-500" />
{t('storageAnalysis.dialogTitle')}
</DialogTitle>
<DialogDescription>
{t('storageAnalysis.description')}
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 pb-4">
<div className="text-sm text-muted-foreground">
{analysis
? t('storageAnalysis.generatedAt', {
time: new Date(analysis.generated_at).toLocaleString(),
})
: t('storageAnalysis.loading')}
</div>
<Button
onClick={loadAnalysis}
variant="outline"
size="sm"
disabled={loading}
>
<RefreshCw
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
/>
{t('storageAnalysis.refresh')}
</Button>
</div>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5">
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{analysis && (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<SummaryItem
label={t('storageAnalysis.totalSize')}
value={formatBytes(totalBytes)}
icon={<HardDrive className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.binaryStorage')}
value={formatBytes(
analysis.database.binary_storage.size_bytes,
)}
meta={`${analysis.database.binary_storage.count}`}
icon={<Database className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.uploadCleanup')}
value={formatBytes(uploadedCandidateBytes)}
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
icon={<FileWarning className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.logCleanup')}
value={formatBytes(logCandidateBytes)}
meta={`${analysis.cleanup_candidates.log_files.length}`}
icon={<FileWarning className="size-4" />}
/>
</div>
<section className="rounded-md border px-3 py-3">
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Clock className="size-4 text-muted-foreground" />
{t('storageAnalysis.cleanupPolicy')}
</h2>
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
<PolicyItem
label={t('storageAnalysis.uploadRetention')}
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.logRetention')}
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.databaseType')}
value={analysis.database.type}
/>
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-medium">
{t('storageAnalysis.sections')}
</h2>
<div className="overflow-hidden rounded-md border">
{analysis.sections.map((section) => (
<div
key={section.key}
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="font-medium">
{t(`storageAnalysis.sectionNames.${section.key}`)}
</div>
<div className="break-all text-xs text-muted-foreground">
{section.path || '-'}
</div>
</div>
{section.exists ? (
<span />
) : (
<Badge variant="outline" className="self-center">
{t('storageAnalysis.missing')}
</Badge>
)}
<div className="self-center tabular-nums">
{formatBytes(section.size_bytes)}
</div>
<div className="self-center text-muted-foreground tabular-nums">
{section.file_count}
</div>
</div>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricPanel
title={t('storageAnalysis.monitoringTables')}
values={analysis.database.monitoring_counts}
/>
<MetricPanel
title={t('storageAnalysis.runtimeTasks')}
values={analysis.tasks}
/>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CandidatePanel
title={t('storageAnalysis.expiredUploads')}
emptyText={t('storageAnalysis.noExpiredUploads')}
candidates={analysis.cleanup_candidates.uploaded_files}
/>
<CandidatePanel
title={t('storageAnalysis.expiredLogs')}
emptyText={t('storageAnalysis.noExpiredLogs')}
candidates={analysis.cleanup_candidates.log_files}
/>
</section>
</>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
function SummaryItem({
label,
value,
icon,
meta,
}: {
label: string;
value: string;
icon: ReactNode;
meta?: string;
}) {
return (
<div className="rounded-md border px-3 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-2 flex items-end justify-between gap-2">
<span className="text-xl font-semibold tabular-nums">{value}</span>
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
</div>
</div>
);
}
function PolicyItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 font-medium">{value}</div>
</div>
);
}
function MetricPanel({
title,
values,
}: {
title: string;
values: Record<string, number | undefined>;
}) {
return (
<div>
<h2 className="mb-2 text-sm font-medium">{title}</h2>
<div className="rounded-md border">
{Object.entries(values).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
>
<span className="text-muted-foreground">{key}</span>
<span className="font-medium tabular-nums">{value ?? '-'}</span>
</div>
))}
</div>
</div>
);
}
function CandidatePanel({
title,
emptyText,
candidates,
}: {
title: string;
emptyText: string;
candidates: CleanupCandidate[];
}) {
return (
<div>
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
<Archive className="size-4 text-muted-foreground" />
{title}
</h2>
<div className="rounded-md border">
{candidates.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
candidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.key ?? candidate.name}-${index}`}
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">
{candidate.key ?? candidate.name}
</div>
<div className="text-xs text-muted-foreground">
{candidate.modified_at ?? candidate.date ?? '-'}
</div>
</div>
<div className="self-center tabular-nums">
{formatBytes(candidate.size_bytes)}
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,391 @@
'use client';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Archive,
Clock,
Database,
FileWarning,
HardDrive,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
interface StorageSection {
key: string;
path: string;
exists: boolean;
size_bytes: number;
file_count: number;
}
interface CleanupCandidate {
key?: string;
name?: string;
size_bytes: number;
modified_at?: string;
date?: string;
}
interface StorageAnalysis {
generated_at: string;
cleanup_policy: {
uploaded_file_retention_days: number;
log_retention_days: number;
};
sections: StorageSection[];
database: {
type: string;
monitoring_counts: Record<string, number>;
binary_storage: {
count: number;
size_bytes: number | null;
};
};
cleanup_candidates: {
uploaded_files: CleanupCandidate[];
log_files: CleanupCandidate[];
};
tasks: Record<string, number | undefined>;
}
interface StorageAnalysisPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) {
return '-';
}
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
export default function StorageAnalysisPanel({
active,
}: StorageAnalysisPanelProps) {
const { t } = useTranslation();
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await backendClient.get<StorageAnalysis>(
'/api/v1/system/storage-analysis',
);
setAnalysis(result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (active) {
loadAnalysis();
}
}, [loadAnalysis, active]);
const totalBytes = useMemo(() => {
return (
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
);
}, [analysis]);
const uploadedCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.uploaded_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
const logCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.log_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
return (
<div className="flex h-full min-h-0 flex-col">
<PanelToolbar>
<div className="text-sm text-muted-foreground">
{analysis
? t('storageAnalysis.generatedAt', {
time: new Date(analysis.generated_at).toLocaleString(),
})
: t('storageAnalysis.loading')}
</div>
<Button
onClick={loadAnalysis}
variant="outline"
size="sm"
disabled={loading}
>
<RefreshCw
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
/>
{t('storageAnalysis.refresh')}
</Button>
</PanelToolbar>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5">
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{analysis && (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<SummaryItem
label={t('storageAnalysis.totalSize')}
value={formatBytes(totalBytes)}
icon={<HardDrive className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.binaryStorage')}
value={formatBytes(
analysis.database.binary_storage.size_bytes,
)}
meta={`${analysis.database.binary_storage.count}`}
icon={<Database className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.uploadCleanup')}
value={formatBytes(uploadedCandidateBytes)}
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
icon={<FileWarning className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.logCleanup')}
value={formatBytes(logCandidateBytes)}
meta={`${analysis.cleanup_candidates.log_files.length}`}
icon={<FileWarning className="size-4" />}
/>
</div>
<section className="rounded-md border px-3 py-3">
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Clock className="size-4 text-muted-foreground" />
{t('storageAnalysis.cleanupPolicy')}
</h2>
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
<PolicyItem
label={t('storageAnalysis.uploadRetention')}
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.logRetention')}
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.databaseType')}
value={analysis.database.type}
/>
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-medium">
{t('storageAnalysis.sections')}
</h2>
<div className="overflow-hidden rounded-md border">
{analysis.sections.map((section) => (
<div
key={section.key}
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="font-medium">
{t(`storageAnalysis.sectionNames.${section.key}`)}
</div>
<div className="break-all text-xs text-muted-foreground">
{section.path || '-'}
</div>
</div>
{section.exists ? (
<span />
) : (
<Badge variant="outline" className="self-center">
{t('storageAnalysis.missing')}
</Badge>
)}
<div className="self-center tabular-nums">
{formatBytes(section.size_bytes)}
</div>
<div className="self-center text-muted-foreground tabular-nums">
{section.file_count}
</div>
</div>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricPanel
title={t('storageAnalysis.monitoringTables')}
values={analysis.database.monitoring_counts}
/>
<MetricPanel
title={t('storageAnalysis.runtimeTasks')}
values={analysis.tasks}
/>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CandidatePanel
title={t('storageAnalysis.expiredUploads')}
emptyText={t('storageAnalysis.noExpiredUploads')}
candidates={analysis.cleanup_candidates.uploaded_files}
/>
<CandidatePanel
title={t('storageAnalysis.expiredLogs')}
emptyText={t('storageAnalysis.noExpiredLogs')}
candidates={analysis.cleanup_candidates.log_files}
/>
</section>
</>
)}
</div>
</ScrollArea>
</div>
);
}
function SummaryItem({
label,
value,
icon,
meta,
}: {
label: string;
value: string;
icon: ReactNode;
meta?: string;
}) {
return (
<div className="rounded-md border px-3 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-2 flex items-end justify-between gap-2">
<span className="text-xl font-semibold tabular-nums">{value}</span>
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
</div>
</div>
);
}
function PolicyItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 font-medium">{value}</div>
</div>
);
}
function MetricPanel({
title,
values,
}: {
title: string;
values: Record<string, number | undefined>;
}) {
return (
<div>
<h2 className="mb-2 text-sm font-medium">{title}</h2>
<div className="rounded-md border">
{Object.entries(values).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
>
<span className="text-muted-foreground">{key}</span>
<span className="font-medium tabular-nums">{value ?? '-'}</span>
</div>
))}
</div>
</div>
);
}
function CandidatePanel({
title,
emptyText,
candidates,
}: {
title: string;
emptyText: string;
candidates: CleanupCandidate[];
}) {
return (
<div>
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
<Archive className="size-4 text-muted-foreground" />
{title}
</h2>
<div className="rounded-md border">
{candidates.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
candidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.key ?? candidate.name}-${index}`}
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">
{candidate.key ?? candidate.name}
</div>
<div className="text-xs text-muted-foreground">
{candidate.modified_at ?? candidate.date ?? '-'}
</div>
</div>
<div className="self-center tabular-nums">
{formatBytes(candidate.size_bytes)}
</div>
</div>
))
)}
</div>
</div>
);
}
@@ -310,6 +310,7 @@ function SingleSelectField({
{options.map((opt) => (
<div key={opt.id}>
<button
type="button"
onClick={() => onChange(opt.id)}
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors ${
value === opt.id
@@ -361,8 +362,16 @@ function MultiSelectField({
const selected = value.includes(opt.id);
return (
<div key={opt.id}>
<button
<div
role="button"
tabIndex={0}
onClick={() => toggle(opt.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle(opt.id);
}
}}
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
selected
? 'border-primary bg-primary/5 text-primary'
@@ -371,7 +380,7 @@ function MultiSelectField({
>
<Checkbox checked={selected} className="pointer-events-none" />
{getI18nText(opt.label)}
</button>
</div>
{opt.has_input && selected && (
<input
type="text"
@@ -1,6 +1,7 @@
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
import { useTranslation } from 'react-i18next';
import styles from './KBCard.module.css';
import { Clock } from 'lucide-react';
export default function KBCard({ kbCardVO }: { kbCardVO: KnowledgeBaseVO }) {
const { t } = useTranslation();
@@ -27,14 +28,7 @@ export default function KBCard({ kbCardVO }: { kbCardVO: KnowledgeBaseVO }) {
</div>
<div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>
<svg
className={`${styles.basicInfoUpdateTimeIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z"></path>
</svg>
<Clock className={`${styles.basicInfoUpdateTimeIcon}`} />
<div className={`${styles.basicInfoUpdateTimeText}`}>
{t('knowledge.updateTime')}
{kbCardVO.lastUpdatedTimeAgo}
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { CloudUpload } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
@@ -221,7 +222,7 @@ export default function FileUploadZone({
{t('knowledge.documentsTab.noParserAvailable')}
</p>
<Link
to="/home/market?category=Parser"
to="/home/add-extension"
className="text-sm text-primary hover:underline mt-1 inline-block"
>
{t('knowledge.documentsTab.installParserHint')}
@@ -297,19 +298,7 @@ export default function FileUploadZone({
<label htmlFor="file-upload" className="cursor-pointer block">
<div className="space-y-2">
<div className="mx-auto w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<CloudUpload className="w-5 h-5 text-gray-400" />
</div>
<div>
@@ -324,7 +324,7 @@ export default function KBForm({
{t('knowledge.noEnginesAvailable')}
</p>
<Link
to="/home/market?category=KnowledgeEngine"
to="/home/add-extension"
className="text-sm text-primary hover:underline"
>
{t('knowledge.installEngineHint')}
+75 -12
View File
@@ -42,27 +42,56 @@ import {
PluginInstallTaskProvider,
PluginInstallProgressDialog,
} from '@/app/home/plugins/components/plugin-install-task';
import { setDocumentTitle } from '@/hooks/useDocumentTitle';
// Routes that belong to the "Extensions" section
const EXTENSIONS_ROUTES = [
'/home/plugins',
'/home/market',
'/home/extensions',
'/home/add-extension',
'/home/mcp',
'/home/skills',
'/home/plugin-pages',
];
// Map a /home route to the i18n key for its type-level title. Used as a robust
// fallback for the document title on direct page loads, before the sidebar's
// onSelectedChange has populated the local `title` state. Detail routes reuse
// the section key (prefix match), e.g. /home/mcp?id=... -> mcp.title.
const HOME_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [
{ match: (p) => p.startsWith('/home/monitoring'), key: 'monitoring.title' },
{ match: (p) => p.startsWith('/home/bots'), key: 'bots.title' },
{ match: (p) => p.startsWith('/home/pipelines'), key: 'pipelines.title' },
{
match: (p) => p.startsWith('/home/add-extension'),
key: 'sidebar.addExtension',
},
{ match: (p) => p.startsWith('/home/extensions'), key: 'plugins.title' },
{ match: (p) => p.startsWith('/home/mcp'), key: 'mcp.title' },
{ match: (p) => p.startsWith('/home/knowledge'), key: 'knowledge.title' },
{ match: (p) => p.startsWith('/home/skills'), key: 'skills.title' },
{
match: (p) => p.startsWith('/home/plugin-pages'),
key: 'sidebar.pluginPages',
},
];
function isExtensionsRoute(pathname: string): boolean {
return EXTENSIONS_ROUTES.some(
(route) => pathname === route || pathname.startsWith(route + '/'),
);
}
const HOME_CONTENT_MAX_WIDTH = 'max-w-[1360px]';
const BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY =
'langbot_backend_unavailable_return_to';
export default function HomeLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const navigate = useNavigate();
const location = useLocation();
// Initialize user info if not already initialized
useEffect(() => {
@@ -73,19 +102,35 @@ export default function HomeLayout({
// Auto-redirect to wizard on first visit (wizard not yet completed on this instance)
useEffect(() => {
let cancelled = false;
const checkWizard = async () => {
try {
// Always re-fetch to ensure we have the latest wizard_status from backend
await initializeSystemInfo();
if (systemInfo.wizard_status === 'none') {
navigate('/wizard');
await initializeSystemInfo({ throwOnError: true });
if (!cancelled && systemInfo.wizard_status === 'none') {
navigate('/wizard', { replace: true });
}
} catch {
// If fetching system info fails, don't redirect
if (!cancelled) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
sessionStorage.setItem(
BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY,
returnTo,
);
navigate('/backend-unavailable', {
replace: true,
state: { from: returnTo },
});
}
}
};
checkWizard();
}, [navigate]);
return () => {
cancelled = true;
};
}, [location.hash, location.pathname, location.search, navigate]);
return (
<SidebarDataProvider>
@@ -123,7 +168,21 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
const sectionLabel = isExtensions
? t('sidebar.extensions')
: t('sidebar.home');
const sectionLink = isExtensions ? '/home/plugins' : '/home/monitoring';
const sectionLink = isExtensions ? '/home/extensions' : '/home/monitoring';
// Drive the browser tab title for the /home section. The type-level label
// prefers the sidebar-provided `title`, falling back to a route-derived key on
// direct page loads. When a sub-entity (plugin / MCP / pipeline / KB / skill)
// is open, its name is prepended: "<entity> · <type> · LangBot".
useEffect(() => {
const routeEntry = HOME_TITLE_KEYS.find((e) => e.match(pathname));
const fallbackType =
routeEntry && t(routeEntry.key) !== routeEntry.key
? t(routeEntry.key)
: null;
const typeLabel = title || fallbackType;
setDocumentTitle(detailEntityName, typeLabel);
}, [pathname, title, detailEntityName, t]);
return (
<SidebarProvider>
@@ -133,7 +192,7 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<div className="flex w-full items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
@@ -177,9 +236,13 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</header>
<div className="flex-1 overflow-hidden p-4 pt-0 min-w-0">
{mainContent}
</div>
<main className="flex-1 overflow-hidden min-w-0 px-4 pb-4 pt-0">
<div
className={`mx-auto h-full w-full min-w-0 ${HOME_CONTENT_MAX_WIDTH}`}
>
{mainContent}
</div>
</main>
<SurveyWidget />
</SidebarInset>
-202
View File
@@ -1,202 +0,0 @@
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Download } from 'lucide-react';
import React, { useState, useCallback, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
enum PluginInstallStatus {
ASK_CONFIRM = 'ask_confirm',
INSTALLING = 'installing',
ERROR = 'error',
}
export default function MarketplacePage() {
const { t } = useTranslation();
if (!systemInfo?.enable_marketplace) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<p className="text-muted-foreground">{t('plugins.marketplace')}</p>
</div>
);
}
return <MarketplaceContent />;
}
function MarketplaceContent() {
const { t } = useTranslation();
const { refreshPlugins } = useSidebarData();
const {
addTask,
setSelectedTaskId,
registerOnTaskComplete,
unregisterOnTaskComplete,
} = usePluginInstallTasks();
const [modalOpen, setModalOpen] = useState(false);
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
const [pluginInstallStatus, setPluginInstallStatus] =
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
const [installError, setInstallError] = useState<string | null>(null);
async function checkExtensionsLimit(): Promise<boolean> {
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
if (maxExtensions < 0) return true;
try {
const [pluginsResp, mcpResp] = await Promise.all([
httpClient.getPlugins(),
httpClient.getMCPServers(),
]);
const total =
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
if (total >= maxExtensions) {
toast.error(
t('limitation.maxExtensionsReached', { max: maxExtensions }),
);
return false;
}
} catch {
// If we can't check, let backend handle it
}
return true;
}
// Register task completion callback for toast and plugin list refresh
useEffect(() => {
const onComplete = (_taskId: number, success: boolean, error?: string) => {
if (success) {
toast.success(t('plugins.installSuccess'));
refreshPlugins();
} else {
toast.error(error || t('plugins.installFailed'));
}
};
registerOnTaskComplete(onComplete);
return () => {
unregisterOnTaskComplete(onComplete);
};
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
const handleInstallPlugin = useCallback(
async (plugin: PluginV4) => {
if (!(await checkExtensionsLimit())) return;
setInstallInfo({
plugin_author: plugin.author,
plugin_name: plugin.name,
plugin_version: plugin.latest_version,
});
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
setInstallError(null);
setModalOpen(true);
},
[t],
);
function handleModalConfirm() {
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
httpClient
.installPluginFromMarketplace(
installInfo.plugin_author,
installInfo.plugin_name,
installInfo.plugin_version,
)
.then((resp) => {
const taskId = resp.task_id;
const taskKey = `marketplace-${taskId}`;
addTask({
taskId,
pluginName: pluginDisplayName,
source: 'marketplace',
});
setSelectedTaskId(taskKey);
setModalOpen(false);
})
.catch((err) => {
setInstallError(err.msg);
setPluginInstallStatus(PluginInstallStatus.ERROR);
});
}
return (
<>
<div className="h-full overflow-y-auto">
<MarketPage installPlugin={handleInstallPlugin} />
</div>
<Dialog
open={modalOpen}
onOpenChange={(open) => {
setModalOpen(open);
if (!open) {
setInstallError(null);
}
}}
>
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<Download className="size-6" />
<span>{t('plugins.installPlugin')}</span>
</DialogTitle>
</DialogHeader>
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<div className="mt-4">
<p className="mb-2">
{t('plugins.askConfirm', {
name: installInfo.plugin_name,
version: installInfo.plugin_version,
})}
</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installing')}</p>
</div>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<div className="mt-4">
<p className="mb-2">{t('plugins.installFailed')}</p>
<p className="mb-2 text-red-500">{installError}</p>
</div>
)}
<DialogFooter>
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
<>
<Button variant="outline" onClick={() => setModalOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleModalConfirm}>
{t('common.confirm')}
</Button>
</>
)}
{pluginInstallStatus === PluginInstallStatus.ERROR && (
<Button variant="default" onClick={() => setModalOpen(false)}>
{t('common.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+186 -84
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
@@ -23,31 +24,43 @@ import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Trash2 } from 'lucide-react';
import { Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
type MCPConnectionState =
| 'connected'
| 'connecting'
| 'error'
| 'disabled'
| 'disconnected';
export default function MCPDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const { refreshMCPServers, mcpServers, setDetailEntityName } =
useSidebarData();
const server = mcpServers.find((s) => s.id === id);
const displayName = (server?.name ?? id).replace(/__/g, '/');
// Set breadcrumb entity name
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('mcp.createServer'));
} else {
const server = mcpServers.find((s) => s.id === id);
setDetailEntityName(server?.name ?? id);
setDetailEntityName(displayName);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, mcpServers, setDetailEntityName, t]);
}, [displayName, isCreateMode, setDetailEntityName, t]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Track whether the form has unsaved changes
const [formDirty, setFormDirty] = useState(false);
// True when the form picked stdio mode but Box is disabled/unreachable —
// saving would create a server that can never start, so block it.
const [saveBlockedByBox, setSaveBlockedByBox] = useState(false);
// Ref to MCPForm for triggering test from header
const formRef = useRef<MCPFormHandle>(null);
@@ -56,13 +69,55 @@ export default function MCPDetailContent({ id }: { id: string }) {
// Enable state managed here so the header switch works
const [serverEnabled, setServerEnabled] = useState(true);
const [enableLoaded, setEnableLoaded] = useState(false);
const [detailRuntimeStatus, setDetailRuntimeStatus] =
useState<MCPRuntimeState | null>(null);
const runtimeStatus = detailRuntimeStatus ?? server?.runtimeStatus;
const currentConnectionState: MCPConnectionState =
(enableLoaded ? serverEnabled : server?.enabled) === false
? 'disabled'
: runtimeStatus === 'connected' ||
runtimeStatus === 'connecting' ||
runtimeStatus === 'error'
? runtimeStatus
: 'disconnected';
const connectionStatusLabel: Record<MCPConnectionState, string> = {
connected: t('mcp.statusConnected'),
connecting: t('mcp.connecting'),
error: t('mcp.statusError'),
disabled: t('mcp.statusDisabled'),
disconnected: t('mcp.statusDisconnected'),
};
const connectionStatusClassName: Record<MCPConnectionState, string> = {
connected:
'border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-950/40 dark:text-green-300',
connecting:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-950/40 dark:text-amber-300',
error:
'border-red-200 bg-red-50 text-red-700 dark:border-red-900/70 dark:bg-red-950/40 dark:text-red-300',
disabled: 'border-muted-foreground/20 bg-muted text-muted-foreground',
disconnected: 'border-muted-foreground/20 bg-muted text-muted-foreground',
};
const connectionDotClassName: Record<MCPConnectionState, string> = {
connected: 'bg-green-500',
connecting: 'bg-amber-500',
error: 'bg-red-500',
disabled: 'bg-muted-foreground/50',
disconnected: 'bg-muted-foreground/50',
};
// Fetch server enable state
useEffect(() => {
if (!isCreateMode) {
setDetailRuntimeStatus(null);
httpClient.getMCPServer(id).then((res) => {
const server = res.server ?? res;
setServerEnabled(server.enable ?? true);
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
setEnableLoaded(true);
});
}
@@ -102,6 +157,10 @@ export default function MCPDetailContent({ id }: { id: string }) {
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
}
const handlePersistedTestComplete = useCallback(async () => {
await refreshMCPServers();
}, [refreshMCPServers]);
function confirmDelete() {
httpClient
.deleteMCPServer(id)
@@ -142,10 +201,24 @@ export default function MCPDetailContent({ id }: { id: string }) {
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('mcp.createServer')}</h1>
<div className="flex items-center gap-2">
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<h1 className="truncate text-xl font-semibold">
{t('mcp.createServer')}
</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Server className="size-3.5" />
{t('mcp.title')}
</Badge>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => navigate('/home/add-extension')}
>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="outline"
@@ -157,6 +230,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
<Button
type="submit"
form="mcp-form"
disabled={saveBlockedByBox}
onClick={async (e) => {
if (!(await checkExtensionsLimit())) {
e.preventDefault();
@@ -168,47 +242,99 @@ export default function MCPDetailContent({ id }: { id: string }) {
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl pb-8">
<MCPForm
ref={formRef}
initServerName={undefined}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onTestingChange={setMcpTesting}
/>
</div>
<div className="min-h-0 flex-1">
<MCPForm
ref={formRef}
initServerName={undefined}
layout="split"
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
/>
</div>
</div>
);
}
const enableControl = enableLoaded && (
<Card>
<CardHeader>
<CardTitle>{t('common.enable')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<Label
htmlFor="mcp-enable-switch"
className="cursor-pointer text-sm font-medium"
>
{t('common.enable')}
</Label>
<Switch
id="mcp-enable-switch"
checked={serverEnabled}
onCheckedChange={handleEnableToggle}
/>
</div>
</CardContent>
</Card>
);
const editActions = (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('mcp.dangerZone')}
</CardTitle>
<CardDescription>{t('mcp.dangerZoneDescription')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{t('mcp.deleteMCPAction')}</p>
<p className="text-sm text-muted-foreground">
{t('mcp.deleteMCPHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
);
// ==================== Edit Mode ====================
return (
<>
<div className="flex h-full flex-col">
{/* Header: title + enable switch + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">{t('mcp.editServer')}</h1>
{enableLoaded && (
<div className="flex items-center gap-2">
<Switch
id="mcp-enable-switch"
checked={serverEnabled}
onCheckedChange={handleEnableToggle}
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-3">
<h1 className="truncate text-xl font-semibold">{displayName}</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Server className="size-3.5" />
{t('mcp.title')}
</Badge>
<Badge
variant="outline"
className={`shrink-0 gap-1.5 text-[0.7rem] ${connectionStatusClassName[currentConnectionState]}`}
>
<span
className={`size-1.5 rounded-full ${connectionDotClassName[currentConnectionState]}`}
/>
<Label
htmlFor="mcp-enable-switch"
className="text-sm text-muted-foreground cursor-pointer"
>
{t('common.enable')}
</Label>
</div>
)}
{connectionStatusLabel[currentConnectionState]}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
@@ -217,57 +343,33 @@ export default function MCPDetailContent({ id }: { id: string }) {
>
{t('common.test')}
</Button>
<Button type="submit" form="mcp-form" disabled={!formDirty}>
<Button
type="submit"
form="mcp-form"
disabled={!formDirty || saveBlockedByBox}
>
{t('common.save')}
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-3xl space-y-6 pb-8">
<MCPForm
ref={formRef}
initServerName={id}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
onTestingChange={setMcpTesting}
/>
{/* Card: Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('mcp.dangerZone')}
</CardTitle>
<CardDescription>
{t('mcp.dangerZoneDescription')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">
{t('mcp.deleteMCPAction')}
</p>
<p className="text-sm text-muted-foreground">
{t('mcp.deleteMCPHint')}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 className="size-4 mr-1.5" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
</div>
<div className="min-h-0 flex-1">
<MCPForm
ref={formRef}
initServerName={id}
layout="split"
sideHeader={enableControl}
sideFooter={editActions}
onFormSubmit={handleFormSubmit}
onNewServerCreated={handleNewServerCreated}
onDirtyChange={setFormDirty}
onTestingChange={setMcpTesting}
onSaveBlockedChange={setSaveBlockedByBox}
onRuntimeInfoChange={(runtimeInfo) =>
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
}
onPersistedTestComplete={handlePersistedTestComplete}
/>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
import { PluginLogEntry } from '@/app/infra/entities/plugin';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RefreshCw } from 'lucide-react';
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
function levelClassName(level: string): string {
switch (level) {
case 'ERROR':
case 'CRITICAL':
return 'text-red-500';
case 'WARNING':
return 'text-amber-500';
case 'DEBUG':
return 'text-gray-400 dark:text-gray-500';
default:
return 'text-gray-700 dark:text-gray-300';
}
}
export default function MCPLogs({ serverName }: { serverName: string }) {
const { t } = useTranslation();
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [level, setLevel] = useState<string>('ALL');
const [autoRefresh, setAutoRefresh] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomRef = useRef(true);
const fetchLogs = useCallback(() => {
setIsLoading(true);
httpClient
.getMcpServerLogs(serverName, 500, level === 'ALL' ? undefined : level)
.then((res) => {
setLogs(res.logs ?? []);
})
.catch(() => {
setLogs([]);
})
.finally(() => {
setIsLoading(false);
});
}, [serverName, level]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// Auto-refresh poll loop.
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchLogs, 3000);
return () => clearInterval(timer);
}, [autoRefresh, fetchLogs]);
// Keep view pinned to bottom when the user is already at the bottom.
useEffect(() => {
const el = scrollRef.current;
if (el && atBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [logs]);
function handleScroll() {
const el = scrollRef.current;
if (!el) return;
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1 pb-3 sm:px-6">
<Select value={level} onValueChange={setLevel}>
<SelectTrigger className="h-8 w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVEL_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt === 'ALL' ? t('mcp.logsLevelAll') : opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={fetchLogs}
disabled={isLoading}
>
<RefreshCw
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
/>
{t('mcp.logsRefresh')}
</Button>
<div className="flex items-center gap-2">
<Switch
id="mcp-logs-auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label
htmlFor="mcp-logs-auto-refresh"
className="cursor-pointer text-sm font-normal text-muted-foreground"
>
{t('mcp.logsAutoRefresh')}
</Label>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-3 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40 sm:px-6"
>
{logs.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{t('mcp.logsEmpty')}
</div>
) : (
logs.map((entry, idx) => (
<div
key={`${entry.ts}-${idx}`}
className={`whitespace-pre-wrap break-all ${levelClassName(
entry.level,
)}`}
>
{entry.text}
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeHighlight from 'rehype-highlight';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import { useTranslation } from 'react-i18next';
import '@/styles/github-markdown.css';
/**
* Renders the README markdown captured from LangBot Space at install time.
* The README is stored on the MCP server record (``server.readme``) so this
* works offline and regardless of the server's runtime/connection state.
*
* MCP marketplace READMEs reference images by absolute URL (the upstream repo),
* so — unlike plugin READMEs — no asset-path rewriting is needed here.
*/
export default function MCPReadme({ readme }: { readme?: string }) {
const { t } = useTranslation();
if (!readme || !readme.trim()) {
return (
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
{t('mcp.noReadme')}
</div>
);
}
return (
<div className="w-full overflow-auto">
<div className="markdown-body max-w-none p-1 pt-0">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[
rehypeRaw,
rehypeSanitize,
rehypeHighlight,
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'wrap',
properties: {
className: ['anchor'],
},
},
],
]}
components={{
ul: ({ children }) => <ul className="list-disc">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal">{children}</ol>,
li: ({ children }) => <li className="ml-4">{children}</li>,
a: ({ children, href, ...props }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
{...props}
>
{children}
</a>
),
img: ({ src, alt, ...props }) => (
<img
src={src}
alt={alt || ''}
className="my-4 h-auto max-w-full rounded-lg"
{...props}
/>
),
}}
>
{readme}
</ReactMarkdown>
</div>
</div>
);
}
@@ -0,0 +1,649 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Bot,
ChevronDown,
ChevronRight,
Clock,
Cpu,
Hash,
User,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { MessageContentRenderer } from './MessageContentRenderer';
import {
ConversationTurn,
hasRenderableMessageContent,
} from '../utils/conversationTurns';
import { MonitoringMessage } from '../types/monitoring';
interface ConversationTurnListProps {
turns: ConversationTurn[];
expandedTurnId: string | null;
onToggleTurn: (turnId: string) => void;
}
function shortId(id?: string) {
if (!id) return '-';
if (id.length <= 12) return id;
return `${id.slice(0, 8)}...${id.slice(-4)}`;
}
function formatDuration(ms: number) {
if (!ms) return '0ms';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function truncateDetail(value?: string) {
if (!value) return '';
return value.length > 1200 ? `${value.slice(0, 1200)}...` : value;
}
function roleLabel(message: MonitoringMessage | undefined) {
const role = message?.role?.toLowerCase();
if (role === 'assistant') return 'assistant';
if (role === 'user') return 'user';
return 'message';
}
function statusClass(level: ConversationTurn['level']) {
if (level === 'error') {
return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300';
}
if (level === 'warning') {
return 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900 dark:bg-yellow-950/40 dark:text-yellow-300';
}
return 'border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300';
}
function Metric({
icon,
label,
tone = 'default',
}: {
icon: React.ReactNode;
label: string;
tone?: 'default' | 'error';
}) {
return (
<span
className={cn(
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs font-medium',
tone === 'error'
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'
: 'border-border bg-background text-muted-foreground',
)}
>
{icon}
{label}
</span>
);
}
function MetaItem({ label, value }: { label: string; value?: string }) {
return (
<div className="min-w-0 rounded-md bg-background px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="truncate text-sm font-medium text-foreground">
{value || '-'}
</div>
</div>
);
}
function MessageLane({
label,
icon,
content,
empty,
maxLines,
}: {
label: string;
icon: React.ReactNode;
content?: string;
empty: string;
maxLines: number;
}) {
return (
<div className="grid grid-cols-[5.25rem_minmax(0,1fr)] items-start gap-3 text-sm sm:grid-cols-[6rem_minmax(0,1fr)]">
<div className="flex h-7 items-center gap-1.5 text-xs font-medium text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<div className="min-w-0 rounded-md bg-muted/45 px-3 py-2 text-foreground">
{content && hasRenderableMessageContent(content) ? (
<MessageContentRenderer content={content} maxLines={maxLines} />
) : (
<span className="italic text-muted-foreground">{empty}</span>
)}
</div>
</div>
);
}
function ExpandedMessage({
message,
label,
}: {
message: MonitoringMessage;
label: string;
}) {
return (
<div className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{label}
</span>
<span>{message.timestamp.toLocaleString()}</span>
<span className="font-mono">ID: {shortId(message.id)}</span>
</div>
<div className="text-sm leading-6 text-foreground">
<MessageContentRenderer content={message.messageContent} maxLines={4} />
</div>
</div>
);
}
export function ConversationTurnList({
turns,
expandedTurnId,
onToggleTurn,
}: ConversationTurnListProps) {
const { t } = useTranslation();
const [expandedToolCallIds, setExpandedToolCallIds] = React.useState<
Record<string, boolean>
>({});
const toggleToolCallDetails = (toolCallKey: string) => {
setExpandedToolCallIds((previous) => ({
...previous,
[toolCallKey]: !previous[toolCallKey],
}));
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{t('monitoring.messageList.turns', {
defaultValue: '{{count}} 轮对话',
count: turns.length,
})}
</span>
</div>
{turns.map((turn) => {
const expanded = expandedTurnId === turn.id;
const firstAssistant = turn.assistantMessages[0];
const assistantOverflow = Math.max(
turn.assistantMessages.length - 1,
0,
);
return (
<div
key={turn.id}
className={cn(
'overflow-hidden rounded-xl border bg-card transition-colors',
turn.level === 'error' && 'border-red-200 dark:border-red-900',
)}
>
<div
role="button"
tabIndex={0}
className="cursor-pointer p-3 outline-none transition-colors hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring sm:p-5"
onClick={() => onToggleTurn(turn.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleTurn(turn.id);
}
}}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex min-w-0 items-center gap-2">
{expanded ? (
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-mono text-xs text-muted-foreground">
Turn: {shortId(turn.id)}
</span>
</div>
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{turn.botName}
</span>
<span className="text-muted-foreground"></span>
<span className="truncate text-sm text-muted-foreground">
{turn.pipelineName}
</span>
{turn.runnerName && (
<>
<span className="text-muted-foreground"></span>
<span className="truncate text-sm text-muted-foreground">
{turn.runnerName}
</span>
</>
)}
</div>
<div className="space-y-2">
<MessageLane
label={t('monitoring.messageList.userMessage', {
defaultValue: '用户',
})}
icon={<User className="h-3.5 w-3.5" />}
content={turn.userMessage?.messageContent}
empty={t('monitoring.messageList.noUserMessage', {
defaultValue: '未记录用户输入',
})}
maxLines={2}
/>
<MessageLane
label={
assistantOverflow > 0
? t('monitoring.messageList.assistantMessageCount', {
defaultValue: '助手 +{{count}}',
count: assistantOverflow,
})
: t('monitoring.messageList.assistantMessage', {
defaultValue: '助手',
})
}
icon={<Bot className="h-3.5 w-3.5" />}
content={firstAssistant?.messageContent}
empty={t('monitoring.messageList.noAssistantMessage', {
defaultValue: '未记录助手回复',
})}
maxLines={2}
/>
</div>
</div>
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
<div className="text-xs text-muted-foreground">
{turn.lastActivityAt.toLocaleString()}
</div>
<div
className={cn(
'inline-flex h-7 items-center rounded-md border px-2 text-xs font-medium',
statusClass(turn.level),
)}
>
{turn.level}
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
<Metric
icon={<Cpu className="h-3.5 w-3.5" />}
label={`${turn.llmCalls.length} LLM`}
/>
{turn.toolCalls.length > 0 && (
<Metric
icon={<Wrench className="h-3.5 w-3.5" />}
label={`${turn.toolCalls.length} tools`}
/>
)}
<Metric
icon={<Hash className="h-3.5 w-3.5" />}
label={`${turn.totalTokens.toLocaleString()} tokens`}
/>
<Metric
icon={<Clock className="h-3.5 w-3.5" />}
label={formatDuration(turn.totalDuration)}
/>
{turn.errors.length > 0 && (
<Metric
icon={<AlertCircle className="h-3.5 w-3.5" />}
label={`${turn.errors.length} errors`}
tone="error"
/>
)}
</div>
</div>
</div>
</div>
{expanded && (
<div className="border-t bg-muted/40 p-3 sm:p-5">
<div className="space-y-5 border-l-2 border-border pl-4 sm:pl-6">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-5">
<MetaItem
label={t('monitoring.messageList.platform', {
defaultValue: '平台',
})}
value={turn.platform}
/>
<MetaItem
label={t('monitoring.messageList.user', {
defaultValue: '用户',
})}
value={turn.userName || turn.userId}
/>
<MetaItem
label={t('monitoring.messageList.runner', {
defaultValue: '执行器',
})}
value={turn.runnerName}
/>
<MetaItem
label={t('monitoring.sessions.sessionId', {
defaultValue: '会话 ID',
})}
value={turn.sessionId}
/>
<MetaItem
label={t('monitoring.messageList.messageCount', {
defaultValue: '消息数',
})}
value={String(turn.messages.length)}
/>
</div>
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Bot className="h-4 w-4" />
{t('monitoring.messageList.conversationTrace', {
defaultValue: '消息链路',
})}
</h4>
<div className="rounded-lg bg-background px-3 py-3">
{turn.messages.map((message) => (
<ExpandedMessage
key={message.id}
message={message}
label={t(
`monitoring.messageList.roles.${roleLabel(message)}`,
{
defaultValue:
roleLabel(message) === 'assistant'
? '助手'
: roleLabel(message) === 'user'
? '用户'
: '消息',
},
)}
/>
))}
</div>
</section>
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Cpu className="h-4 w-4" />
{t('monitoring.llmCalls.title', {
defaultValue: 'LLM 调用',
})}{' '}
({turn.llmCalls.length})
</h4>
<div className="grid grid-cols-3 gap-2">
<MetaItem
label={t('monitoring.llmCalls.totalTokens', {
defaultValue: '总 Token',
})}
value={turn.totalTokens.toLocaleString()}
/>
<MetaItem
label={t('monitoring.llmCalls.inputTokens', {
defaultValue: '输入 Token',
})}
value={turn.inputTokens.toLocaleString()}
/>
<MetaItem
label={t('monitoring.llmCalls.duration', {
defaultValue: '耗时',
})}
value={formatDuration(turn.totalDuration)}
/>
</div>
<div className="mt-3 rounded-lg bg-background px-3 py-3">
{turn.llmCalls.length > 0 ? (
turn.llmCalls.map((call, index) => (
<div
key={call.id}
className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">
#{index + 1} {call.modelName}
</span>
<span
className={cn(
'rounded-md px-2 py-1 text-xs font-medium',
call.status === 'success'
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
)}
>
{call.status}
</span>
</div>
<span className="text-xs text-muted-foreground">
{formatDuration(call.duration)}
</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
<span>In: {call.tokens.input}</span>
<span>Out: {call.tokens.output}</span>
<span>Total: {call.tokens.total}</span>
<span className="font-mono">
ID: {shortId(call.id)}
</span>
</div>
{call.errorMessage && (
<div className="mt-2 whitespace-pre-wrap break-words text-xs text-red-600 dark:text-red-400">
{call.errorMessage}
</div>
)}
</div>
))
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
{t('monitoring.messageList.noLlmCalls', {
defaultValue: '未记录模型调用',
})}
</div>
)}
</div>
</section>
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Wrench className="h-4 w-4" />
{t('monitoring.toolCalls.title', {
defaultValue: '工具调用',
})}{' '}
({turn.toolCalls.length})
</h4>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3">
<MetaItem
label={t('monitoring.toolCalls.totalCalls', {
defaultValue: '调用次数',
})}
value={String(turn.toolCalls.length)}
/>
<MetaItem
label={t('monitoring.toolCalls.duration', {
defaultValue: '工具耗时',
})}
value={formatDuration(turn.totalToolDuration)}
/>
<MetaItem
label={t('monitoring.toolCalls.errorCalls', {
defaultValue: '失败次数',
})}
value={String(
turn.toolCalls.filter(
(call) => call.status === 'error',
).length,
)}
/>
</div>
<div className="mt-3 rounded-lg bg-background px-3 py-3">
{turn.toolCalls.length > 0 ? (
turn.toolCalls.map((call, index) => {
const toolCallKey = `${turn.id}:${call.id}`;
const hasToolDetails = Boolean(
call.arguments || call.result || call.errorMessage,
);
const expandedToolCall = Boolean(
expandedToolCallIds[toolCallKey],
);
const detailsId = `monitoring-tool-call-details-${call.id}`;
return (
<div
key={call.id}
className="border-t border-border/70 py-2 first:border-t-0 first:pt-0 last:pb-0"
>
<button
type="button"
className={cn(
'flex w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors',
hasToolDetails &&
'cursor-pointer hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
)}
aria-expanded={
hasToolDetails ? expandedToolCall : undefined
}
aria-controls={
hasToolDetails ? detailsId : undefined
}
aria-disabled={!hasToolDetails}
onClick={() =>
hasToolDetails &&
toggleToolCallDetails(toolCallKey)
}
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{hasToolDetails &&
(expandedToolCall ? (
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
))}
<span className="min-w-0 truncate text-sm font-medium text-foreground">
#{index + 1} {call.toolName}
</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
{call.toolSource}
</span>
<span
className={cn(
'rounded-md px-2 py-1 text-xs font-medium',
call.status === 'success'
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
)}
>
{call.status}
</span>
<span className="font-mono text-xs text-muted-foreground">
ID: {shortId(call.id)}
</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{formatDuration(call.duration)}
</span>
</button>
{hasToolDetails && expandedToolCall && (
<div
id={detailsId}
className="mt-1 grid gap-2 px-2 pb-2 text-xs lg:grid-cols-2"
>
{call.arguments && (
<div className="min-w-0 rounded-md bg-muted/50 p-2">
<div className="mb-1 font-medium text-foreground">
{t('monitoring.toolCalls.arguments', {
defaultValue: '参数',
})}
</div>
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
{truncateDetail(call.arguments)}
</pre>
</div>
)}
{call.result && (
<div className="min-w-0 rounded-md bg-muted/50 p-2">
<div className="mb-1 font-medium text-foreground">
{t('monitoring.toolCalls.result', {
defaultValue: '结果',
})}
</div>
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
{truncateDetail(call.result)}
</pre>
</div>
)}
{call.errorMessage && (
<div className="min-w-0 whitespace-pre-wrap break-words rounded-md bg-red-50 p-2 text-red-600 dark:bg-red-950/40 dark:text-red-400 lg:col-span-2">
{call.errorMessage}
</div>
)}
</div>
)}
</div>
);
})
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
{t('monitoring.toolCalls.noToolCalls', {
defaultValue: '未记录工具调用',
})}
</div>
)}
</div>
</section>
{turn.errors.length > 0 && (
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-700 dark:text-red-300">
<AlertCircle className="h-4 w-4" />
{t('monitoring.errors.title', {
defaultValue: '错误日志',
})}{' '}
({turn.errors.length})
</h4>
<div className="rounded-lg bg-background px-3 py-3">
{turn.errors.map((error) => (
<div
key={error.id}
className="border-t border-red-200/80 py-3 first:border-t-0 first:pt-0 last:pb-0 dark:border-red-900"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium text-red-700 dark:text-red-300">
{error.errorType}
</span>
<span className="text-xs text-muted-foreground">
{error.timestamp.toLocaleString()}
</span>
</div>
<div className="whitespace-pre-wrap break-words text-sm text-red-600 dark:text-red-400">
{error.errorMessage}
</div>
</div>
))}
</div>
</section>
)}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
@@ -6,6 +6,8 @@ import {
TrendingUp,
TrendingDown,
Minus,
Heart,
Smile,
} from 'lucide-react';
interface FeedbackCardProps {
@@ -133,11 +135,7 @@ export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
{
title: t('monitoring.feedback.totalFeedback'),
value: stats?.totalFeedback ?? 0,
icon: (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
),
icon: <Heart className="w-6 h-6" />,
variant: 'default' as const,
},
{
@@ -155,11 +153,7 @@ export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
{
title: t('monitoring.feedback.satisfactionRate'),
value: stats ? `${stats.satisfactionRate}%` : '0%',
icon: (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
</svg>
),
icon: <Smile className="w-6 h-6" />,
variant: (stats && stats.satisfactionRate >= 80
? 'success'
: stats && stats.satisfactionRate >= 50
@@ -6,6 +6,7 @@ import {
ChevronRight,
ChevronDown,
ExternalLink,
Heart,
} from 'lucide-react';
import { FeedbackRecord } from '../types/monitoring';
import { Button } from '@/components/ui/button';
@@ -40,19 +41,7 @@ export function FeedbackList({
if (!feedback || feedback.length === 0) {
return (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<Heart className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium mb-2">
{t('monitoring.feedback.noFeedback')}
</p>
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
import { Paperclip, AudioLines } from 'lucide-react';
import {
MessageChainComponent,
Image as ImageComponent,
@@ -104,13 +105,7 @@ export function MessageContentRenderer({
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
<svg
className="w-3.5 h-3.5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" />
</svg>
<Paperclip className="w-3.5 h-3.5 mr-1" />
{file.name || 'File'}
</span>
);
@@ -123,13 +118,7 @@ export function MessageContentRenderer({
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
<svg
className="w-3.5 h-3.5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
<AudioLines className="w-3.5 h-3.5 mr-1" />
Voice{voice.length ? ` ${voice.length}s` : ''}
</span>
);
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Info, Clock, AlertCircle, Braces } from 'lucide-react';
import { MessageDetails } from '../types/monitoring';
interface MessageDetailsCardProps {
@@ -25,14 +26,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{details.message && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 7H13V9H11V7ZM11 11H13V17H11V11Z"></path>
</svg>
<Info className="w-4 h-4 mr-2" />
{t('monitoring.messageList.viewDetails')}
</h4>
@@ -92,14 +86,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{details.llmCalls && details.llmCalls.length > 0 && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2C17.52 2 22 6.48 22 12C22 17.52 17.52 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2ZM12 20C16.42 20 20 16.42 20 12C20 7.58 16.42 4 12 4C7.58 4 4 7.58 4 12C4 16.42 7.58 20 12 20ZM13 12V7H11V14H17V12H13Z"></path>
</svg>
<Clock className="w-4 h-4 mr-2" />
{t('monitoring.llmCalls.title')} ({details.llmCalls.length})
</h4>
@@ -183,14 +170,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{details.errors && details.errors.length > 0 && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM11 7H13V13H11V7Z"></path>
</svg>
<AlertCircle className="w-4 h-4 mr-2" />
{t('monitoring.errors.title')} ({details.errors.length})
</h4>
<div className="space-y-2">
@@ -227,14 +207,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
details.message?.runnerName !== 'local-agent' && (
<div className="bg-muted rounded-lg p-3">
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V9.7C6 10.7065 5.41099 11.5849 4.55132 12C5.41099 12.4151 6 13.2935 6 14.3V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V14.3C18 13.2935 18.589 12.4151 19.4487 12C18.589 11.5849 18 10.7065 18 9.7V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z"></path>
</svg>
<Braces className="w-4 h-4 mr-2" />
{t('monitoring.queryVariables.title')}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
@@ -0,0 +1,463 @@
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
ComposedChart,
Area,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import {
Coins,
ArrowDownToLine,
ArrowUpFromLine,
Gauge,
AlertTriangle,
TrendingUp,
} from 'lucide-react';
import { httpClient } from '@/app/infra/http/HttpClient';
interface TokenSummary {
total_calls: number;
success_calls: number;
error_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_cost: number;
avg_tokens_per_call: number;
avg_duration_ms: number;
avg_tokens_per_second: number;
zero_token_success_calls: number;
}
interface TokenByModel {
model_name: string;
calls: number;
error_calls: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
cost: number;
avg_tokens_per_call: number;
avg_duration_ms: number;
}
interface TokenTimeseriesPoint {
bucket: string;
input_tokens: number;
output_tokens: number;
total_tokens: number;
calls: number;
}
interface TokenStatistics {
summary: TokenSummary;
by_model: TokenByModel[];
timeseries: TokenTimeseriesPoint[];
bucket: string;
}
interface TokenMonitoringProps {
botIds?: string[];
pipelineIds?: string[];
startTime?: string;
endTime?: string;
/** Bumped by the parent to trigger a refetch on manual refresh. */
refreshKey?: number;
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}
const TOOLTIP_STYLE: React.CSSProperties = {
backgroundColor: 'var(--card)',
border: '1px solid var(--border)',
borderRadius: '12px',
boxShadow:
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
fontSize: '13px',
padding: '12px',
color: 'var(--foreground)',
};
function MetricTile({
icon,
label,
value,
sub,
accent,
}: {
icon: React.ReactNode;
label: string;
value: string;
sub?: string;
accent?: string;
}) {
return (
<div className="bg-card rounded-xl border p-4 flex flex-col gap-2">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<span
className="flex items-center justify-center h-7 w-7 rounded-lg"
style={{
backgroundColor: accent ? `${accent}1a` : 'var(--muted)',
color: accent || 'var(--foreground)',
}}
>
{icon}
</span>
{label}
</div>
<div className="text-2xl font-semibold text-foreground tabular-nums">
{value}
</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
export default function TokenMonitoring({
botIds,
pipelineIds,
startTime,
endTime,
refreshKey,
}: TokenMonitoringProps) {
const { t } = useTranslation();
const [bucket, setBucket] = useState<'hour' | 'day'>('hour');
const [stats, setStats] = useState<TokenStatistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const botIdsKey = JSON.stringify(botIds);
const pipelineIdsKey = JSON.stringify(pipelineIds);
const fetchStats = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await httpClient.getTokenStatistics({
botId: botIds,
pipelineId: pipelineIds,
startTime,
endTime,
bucket,
});
setStats(result);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [botIdsKey, pipelineIdsKey, startTime, endTime, bucket, refreshKey]);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const chartData = useMemo(() => {
if (!stats || !Array.isArray(stats.timeseries)) return [];
return stats.timeseries.map((p) => ({
bucket: p.bucket,
input: p.input_tokens,
output: p.output_tokens,
total: p.total_tokens,
}));
}, [stats]);
if (loading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="bg-card rounded-xl border p-4 h-24 animate-pulse"
/>
))}
</div>
<div className="bg-card rounded-xl border p-6 h-[320px] animate-pulse" />
</div>
);
}
if (error) {
return (
<div className="bg-card rounded-xl border p-6 text-sm text-destructive flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
{t('monitoring.tokens.loadError', { error })}
</div>
);
}
if (!stats || !stats.summary || stats.summary.total_calls === 0) {
return (
<div className="bg-card rounded-xl border p-6">
<div className="h-[260px] flex flex-col items-center justify-center text-muted-foreground gap-2">
<Coins className="h-[3rem] w-[3rem]" />
<div className="text-sm">{t('monitoring.tokens.noData')}</div>
</div>
</div>
);
}
const summary = stats.summary;
const by_model = Array.isArray(stats.by_model) ? stats.by_model : [];
return (
<div className="space-y-6">
{/* Data-quality warning: streamed calls that recorded 0 tokens */}
{summary.zero_token_success_calls > 0 && (
<div className="bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-400 rounded-xl p-4 text-sm flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>
{t('monitoring.tokens.zeroTokenWarning', {
count: summary.zero_token_success_calls,
})}
</span>
</div>
)}
{/* Summary tiles */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<MetricTile
icon={<Coins className="h-4 w-4" />}
label={t('monitoring.tokens.totalTokens')}
value={formatNumber(summary.total_tokens)}
sub={t('monitoring.tokens.acrossCalls', {
count: summary.total_calls,
})}
accent="#8b5cf6"
/>
<MetricTile
icon={<ArrowDownToLine className="h-4 w-4" />}
label={t('monitoring.tokens.inputTokens')}
value={formatNumber(summary.total_input_tokens)}
accent="#3b82f6"
/>
<MetricTile
icon={<ArrowUpFromLine className="h-4 w-4" />}
label={t('monitoring.tokens.outputTokens')}
value={formatNumber(summary.total_output_tokens)}
accent="#10b981"
/>
<MetricTile
icon={<TrendingUp className="h-4 w-4" />}
label={t('monitoring.tokens.avgPerCall')}
value={formatNumber(summary.avg_tokens_per_call)}
accent="#f59e0b"
/>
<MetricTile
icon={<Gauge className="h-4 w-4" />}
label={t('monitoring.tokens.throughput')}
value={`${summary.avg_tokens_per_second}`}
sub={t('monitoring.tokens.tokensPerSec')}
accent="#06b6d4"
/>
<MetricTile
icon={<AlertTriangle className="h-4 w-4" />}
label={t('monitoring.tokens.errorCalls')}
value={`${summary.error_calls}`}
sub={t('monitoring.tokens.ofTotal', { count: summary.total_calls })}
accent="#ef4444"
/>
</div>
{/* Token usage over time */}
<div className="bg-card rounded-xl border p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-base font-semibold text-foreground">
{t('monitoring.tokens.usageOverTime')}
</h3>
<div className="inline-flex rounded-lg border p-0.5 text-sm">
{(['hour', 'day'] as const).map((b) => (
<button
key={b}
onClick={() => setBucket(b)}
className={`px-3 py-1 rounded-md transition-colors ${
bucket === b
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(`monitoring.tokens.bucket.${b}`)}
</button>
))}
</div>
</div>
<div className="h-[320px]">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="tokTotal" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.35} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0.03} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
/>
<XAxis
dataKey="bucket"
tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
tickLine={false}
axisLine={{ stroke: 'var(--border)' }}
dy={10}
/>
<YAxis
tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
tickLine={false}
axisLine={{ stroke: 'var(--border)' }}
width={48}
tickFormatter={(v) => formatNumber(Number(v))}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
labelStyle={{
fontWeight: 600,
marginBottom: '8px',
color: 'var(--foreground)',
}}
formatter={(value: number) => formatNumber(Number(value))}
/>
<Legend
wrapperStyle={{
fontSize: '13px',
paddingTop: '16px',
fontWeight: 500,
}}
iconType="circle"
iconSize={10}
/>
<Bar
dataKey="input"
name={t('monitoring.tokens.inputTokens')}
stackId="io"
fill="#3b82f6"
radius={[0, 0, 0, 0]}
barSize={18}
/>
<Bar
dataKey="output"
name={t('monitoring.tokens.outputTokens')}
stackId="io"
fill="#10b981"
radius={[4, 4, 0, 0]}
barSize={18}
/>
<Area
type="monotone"
dataKey="total"
name={t('monitoring.tokens.totalTokens')}
stroke="#8b5cf6"
strokeWidth={2.5}
fill="url(#tokTotal)"
dot={false}
activeDot={{ r: 5, strokeWidth: 2 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
{/* Per-model breakdown */}
<div className="bg-card rounded-xl border p-6">
<h3 className="text-base font-semibold text-foreground mb-4">
{t('monitoring.tokens.byModel')}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground border-b">
<th className="py-2 pr-4 font-medium">
{t('monitoring.tokens.model')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.calls')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.inputTokens')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.outputTokens')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.totalTokens')}
</th>
<th className="py-2 px-4 font-medium text-right">
{t('monitoring.tokens.avgPerCall')}
</th>
<th className="py-2 pl-4 font-medium text-right">
{t('monitoring.tokens.avgLatency')}
</th>
</tr>
</thead>
<tbody>
{by_model.map((m) => {
const share =
summary.total_tokens > 0
? (m.total_tokens / summary.total_tokens) * 100
: 0;
return (
<tr
key={m.model_name}
className="border-b last:border-0 hover:bg-muted/40 transition-colors"
>
<td className="py-2.5 pr-4">
<div className="font-medium text-foreground">
{m.model_name}
</div>
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-violet-500"
style={{ width: `${share}%` }}
/>
</div>
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{m.calls}
{m.error_calls > 0 && (
<span className="text-destructive">
{' '}
({m.error_calls})
</span>
)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatNumber(m.input_tokens)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatNumber(m.output_tokens)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums font-medium">
{formatNumber(m.total_tokens)}
</td>
<td className="py-2.5 px-4 text-right tabular-nums">
{formatNumber(m.avg_tokens_per_call)}
</td>
<td className="py-2.5 pl-4 text-right tabular-nums">
{m.avg_duration_ms}ms
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}
@@ -109,10 +109,10 @@ export default function MonitoringFilters({
};
return (
<div className="flex flex-wrap items-center gap-6">
<div className="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-6">
{/* Bot Filter */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-foreground whitespace-nowrap">
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
{t('monitoring.filters.bot')}
</label>
<Select
@@ -120,7 +120,7 @@ export default function MonitoringFilters({
onValueChange={handleBotChange}
disabled={loadingBots}
>
<SelectTrigger className="h-9 w-[140px]">
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue
placeholder={
loadingBots
@@ -144,7 +144,7 @@ export default function MonitoringFilters({
{/* Pipeline Filter */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-foreground whitespace-nowrap">
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
{t('monitoring.filters.pipeline')}
</label>
<Select
@@ -152,7 +152,7 @@ export default function MonitoringFilters({
onValueChange={handlePipelineChange}
disabled={loadingPipelines}
>
<SelectTrigger className="h-9 w-[140px]">
<SelectTrigger className="h-9 w-full sm:w-[140px]">
<SelectValue
placeholder={
loadingPipelines
@@ -176,11 +176,11 @@ export default function MonitoringFilters({
{/* Time Range Filter */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-foreground whitespace-nowrap">
<label className="w-20 shrink-0 text-sm font-medium text-foreground sm:w-auto sm:whitespace-nowrap">
{t('monitoring.filters.timeRange')}
</label>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="h-9 w-[150px]">
<SelectTrigger className="h-9 w-full sm:w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1,4 +1,5 @@
import React from 'react';
import { TrendingUp, TrendingDown } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
interface MetricCardProps {
@@ -61,21 +62,11 @@ export default function MetricCard({
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
{trend.direction === 'up' ? (
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
)}
</svg>
{trend.direction === 'up' ? (
<TrendingUp className="w-3 h-3" />
) : (
<TrendingDown className="w-3 h-3" />
)}
{Math.abs(trend.value)}%
</span>
<span className="text-xs text-muted-foreground">
@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
import MetricCard from './MetricCard';
import SystemStatusCard from './SystemStatusCards';
import TrafficChart from './TrafficChart';
import {
OverviewMetrics,
@@ -13,6 +15,7 @@ interface OverviewCardsProps {
messages?: MonitoringMessage[];
llmCalls?: LLMCall[];
loading?: boolean;
refreshKey?: number;
}
export default function OverviewCards({
@@ -20,6 +23,7 @@ export default function OverviewCards({
messages = [],
llmCalls = [],
loading,
refreshKey,
}: OverviewCardsProps) {
const { t } = useTranslation();
@@ -27,15 +31,7 @@ export default function OverviewCards({
{
title: t('monitoring.totalMessages'),
value: metrics?.totalMessages || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM4 18.3851L5.76282 17H20V5H4V18.3851Z"></path>
</svg>
),
icon: <MessageSquare />,
trend: metrics?.trends
? {
value: metrics.trends.messages,
@@ -48,15 +44,7 @@ export default function OverviewCards({
{
title: t('monitoring.modelCallsCount'),
value: metrics?.modelCalls || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899Z"></path>
</svg>
),
icon: <Sparkles />,
trend: metrics?.trends
? {
value: metrics.trends.llmCalls,
@@ -69,15 +57,7 @@ export default function OverviewCards({
{
title: t('monitoring.successRate'),
value: metrics ? `${metrics.successRate}%` : '0%',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10 15.172L19.192 5.979L20.607 7.393L10 18L3.636 11.636L5.05 10.222L10 15.172Z"></path>
</svg>
),
icon: <Check />,
trend: metrics?.trends
? {
value: metrics.trends.successRate,
@@ -90,15 +70,7 @@ export default function OverviewCards({
{
title: t('monitoring.activeSessions'),
value: metrics?.activeSessions || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.7519 23 22H21C21 19.3742 19.4041 17.1096 17.1582 16.2466L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
</svg>
),
icon: <Users />,
trend: metrics?.trends
? {
value: metrics.trends.sessions,
@@ -112,8 +84,8 @@ export default function OverviewCards({
return (
<div className="space-y-6">
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
{/* Metric Cards + System Status */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-6">
{cards.map((card, index) => (
<MetricCard
key={index}
@@ -124,6 +96,7 @@ export default function OverviewCards({
loading={loading}
/>
))}
<SystemStatusCard refreshKey={refreshKey} />
</div>
{/* Traffic Chart */}
@@ -0,0 +1,411 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import {
Plug,
Box,
CircleCheck,
CircleX,
Loader2,
Info,
Container,
Clock,
Cpu,
HardDrive,
Network,
Image,
FolderOpen,
} from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
ApiRespPluginSystemStatus,
ApiRespBoxStatus,
BoxSessionInfo,
} from '@/app/infra/entities/api';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { httpClient } from '@/app/infra/http/HttpClient';
type StatusState = 'ok' | 'disabled' | 'failed' | null;
function StatusDot({ state }: { state: StatusState }) {
if (state === null)
return <span className="w-2 h-2 rounded-full bg-muted-foreground/40" />;
if (state === 'ok')
return <span className="w-2 h-2 rounded-full bg-green-500" />;
if (state === 'disabled')
return <span className="w-2 h-2 rounded-full bg-muted-foreground/60" />;
return <span className="w-2 h-2 rounded-full bg-red-500" />;
}
interface SystemStatusCardProps {
refreshKey?: number;
}
export default function SystemStatusCard({
refreshKey,
}: SystemStatusCardProps) {
const { t } = useTranslation();
const [pluginStatus, setPluginStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const [boxStatus, setBoxStatus] = useState<ApiRespBoxStatus | null>(null);
const [boxSessions, setBoxSessions] = useState<BoxSessionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const fetchStatus = useCallback(async () => {
try {
const [plugin, box] = await Promise.all([
httpClient.getPluginSystemStatus().catch(() => null),
httpClient.getBoxStatus().catch(() => null),
]);
const sessions = box?.hidden
? []
: await httpClient.getBoxSessions().catch(() => [] as BoxSessionInfo[]);
setPluginStatus(plugin);
setBoxStatus(box);
setBoxSessions(Array.isArray(sessions) ? sessions : []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 30_000);
return () => clearInterval(interval);
}, [fetchStatus, refreshKey]);
const pluginOk = pluginStatus
? pluginStatus.is_enable && pluginStatus.is_connected
: null;
const pluginState: StatusState = pluginStatus
? pluginStatus.is_enable && pluginStatus.is_connected
? 'ok'
: !pluginStatus.is_enable
? 'disabled'
: 'failed'
: null;
const boxOk = boxStatus ? boxStatus.available : null;
const hideBoxRuntime = boxStatus?.hidden === true;
// Box has three observable states: connected (ok), disabled by config
// (enabled = false → distinct gray dot + "disabled" hint), and configured
// but failed (red dot + connector_error). The dashboard must distinguish
// them so operators can tell intentional-off from misconfigured.
const boxState: StatusState = boxStatus
? boxStatus.available
? 'ok'
: boxStatus.enabled === false
? 'disabled'
: 'failed'
: null;
const handleOpenDialog = (e: React.MouseEvent) => {
e.stopPropagation();
fetchStatus();
setDialogOpen(true);
};
if (loading) {
return (
<Card className="transition-all duration-300">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('monitoring.systemStatus')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<>
<Card className="transition-all duration-300 group">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t('monitoring.systemStatus')}
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={handleOpenDialog}
>
<Info className="w-4 h-4" />
</Button>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center gap-2">
<StatusDot state={pluginState} />
<Plug className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm">{t('monitoring.pluginRuntime')}</span>
</div>
{!hideBoxRuntime && (
<div className="flex items-center gap-2">
<StatusDot state={boxState} />
<Box className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
</div>
)}
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('monitoring.systemStatus')}</DialogTitle>
</DialogHeader>
<TooltipProvider>
<div className="space-y-5 overflow-y-auto flex-1 pr-1">
{/* Plugin Runtime */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Plug className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold">
{t('monitoring.pluginRuntime')}
</span>
</div>
<div className="ml-6 text-sm space-y-1">
<div className="flex items-center gap-1.5">
{pluginOk ? (
<CircleCheck className="w-4 h-4 text-green-600" />
) : (
<CircleX className="w-4 h-4 text-red-500" />
)}
<span
className={
pluginOk
? 'text-green-600 font-medium'
: 'text-red-500 font-medium'
}
>
{pluginOk
? t('monitoring.connected')
: pluginStatus && !pluginStatus.is_enable
? t('monitoring.disabled')
: t('monitoring.disconnected')}
</span>
</div>
{pluginStatus && !pluginStatus.is_enable && (
<p className="text-muted-foreground text-xs">
{t('monitoring.pluginDisabled')}
</p>
)}
{pluginStatus &&
!pluginOk &&
pluginStatus.is_enable &&
pluginStatus.plugin_connector_error &&
pluginStatus.plugin_connector_error !== 'ok' && (
<p className="text-red-400 text-xs break-all">
{pluginStatus.plugin_connector_error}
</p>
)}
</div>
</div>
{!hideBoxRuntime && (
<>
<div className="border-t" />
{/* Box Runtime */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Box className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-semibold">
{t('monitoring.boxRuntime')}
</span>
</div>
<div className="ml-6 text-sm space-y-1">
<div className="flex items-center gap-1.5">
{boxState === 'ok' ? (
<CircleCheck className="w-4 h-4 text-green-600" />
) : (
<CircleX
className={
boxState === 'disabled'
? 'w-4 h-4 text-muted-foreground'
: 'w-4 h-4 text-red-500'
}
/>
)}
<span
className={
boxState === 'ok'
? 'text-green-600 font-medium'
: boxState === 'disabled'
? 'text-muted-foreground font-medium'
: 'text-red-500 font-medium'
}
>
{boxState === 'ok'
? t('monitoring.connected')
: boxState === 'disabled'
? t('monitoring.disabled')
: t('monitoring.disconnected')}
</span>
</div>
{boxState === 'disabled' && (
<p className="text-muted-foreground text-xs">
{t('monitoring.boxDisabled')}
</p>
)}
{boxState === 'failed' && boxStatus?.connector_error && (
<p className="text-red-400 text-xs break-all">
{boxStatus.connector_error}
</p>
)}
{boxStatus && (
<div className="text-muted-foreground text-xs space-y-0.5">
{boxStatus.backend && (
<p>
{t('monitoring.boxBackend')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.backend.name}
</span>
</p>
)}
<p>
{t('monitoring.boxProfile')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.profile}
</span>
</p>
{boxOk && boxStatus.active_sessions !== undefined && (
<p>
{t('monitoring.boxSandboxes')}:{' '}
<span className="text-foreground font-mono">
{boxStatus.active_sessions}
</span>
</p>
)}
</div>
)}
{/* Active Sandboxes */}
{boxSessions.length > 0 && (
<div className="mt-3 space-y-2">
{boxSessions.map((session) => (
<div
key={session.session_id}
className="rounded-lg border p-3 space-y-2"
>
<div className="flex items-center gap-1.5 min-w-0">
<Container className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="font-mono font-semibold text-foreground truncate text-sm">
{session.session_id}
</span>
</TooltipTrigger>
<TooltipContent>
{session.session_id}
</TooltipContent>
</Tooltip>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground min-w-0">
<Image className="w-3 h-3 flex-shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-foreground font-mono truncate">
{session.image}
</span>
</TooltipTrigger>
<TooltipContent>
{session.image}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<HardDrive className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.backend_name}
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Cpu className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.cpus} CPU / {session.memory_mb} MB
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Network className="w-3 h-3 flex-shrink-0" />
<span className="text-foreground">
{session.network}
</span>
</div>
{session.host_path && (
<div className="flex items-center gap-1.5 text-muted-foreground col-span-2 min-w-0">
<FolderOpen className="w-3 h-3 flex-shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-foreground font-mono truncate">
{session.host_path} :{' '}
{session.mount_path}{' '}
<span className="text-muted-foreground">
({session.host_path_mode})
</span>
</span>
</TooltipTrigger>
<TooltipContent>
{session.host_path} :{' '}
{session.mount_path} (
{session.host_path_mode})
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionCreated')}:{' '}
<span className="text-foreground">
{new Date(
session.created_at,
).toLocaleString()}
</span>
</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Clock className="w-3 h-3 flex-shrink-0" />
<span>
{t('monitoring.boxSessionLastUsed')}:{' '}
<span className="text-foreground">
{new Date(
session.last_used_at,
).toLocaleString()}
</span>
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
</TooltipProvider>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { BarChart3 } from 'lucide-react';
import {
AreaChart,
Area,
@@ -33,14 +34,16 @@ export default function TrafficChart({
const { t } = useTranslation();
const chartData = useMemo(() => {
if (!messages.length && !llmCalls.length) {
const safeMessages = Array.isArray(messages) ? messages : [];
const safeLlmCalls = Array.isArray(llmCalls) ? llmCalls : [];
if (!safeMessages.length && !safeLlmCalls.length) {
return [];
}
// Combine all timestamps and find the range
const allTimestamps = [
...messages.map((m) => m.timestamp.getTime()),
...llmCalls.map((c) => c.timestamp.getTime()),
...safeMessages.map((m) => m.timestamp.getTime()),
...safeLlmCalls.map((c) => c.timestamp.getTime()),
];
if (allTimestamps.length === 0) return [];
@@ -98,7 +101,7 @@ export default function TrafficChart({
}
// Count messages per bucket
messages.forEach((msg) => {
safeMessages.forEach((msg) => {
const bucket =
Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
@@ -108,7 +111,7 @@ export default function TrafficChart({
});
// Count LLM calls per bucket
llmCalls.forEach((call) => {
safeLlmCalls.forEach((call) => {
const bucket =
Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
@@ -146,14 +149,7 @@ export default function TrafficChart({
{t('monitoring.trafficChart.title')}
</h3>
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 13H8V21H2V13ZM16 8H22V21H16V8ZM9 3H15V21H9V3ZM4 15V19H6V15H4ZM11 5V19H13V5H11ZM18 10V19H20V10H18Z"></path>
</svg>
<BarChart3 className="h-[3rem] w-[3rem]" />
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
</div>
</div>
@@ -92,18 +92,50 @@ export function useMonitoringData(filterState: FilterState) {
limit: 50,
});
const overview = response?.overview ?? {
total_messages: 0,
llm_calls: 0,
embedding_calls: 0,
model_calls: 0,
success_rate: 100,
active_sessions: 0,
};
const messages = Array.isArray(response?.messages)
? response.messages
: [];
const llmCalls = Array.isArray(response?.llmCalls)
? response.llmCalls
: [];
const toolCalls = Array.isArray(response?.toolCalls)
? response.toolCalls
: [];
const embeddingCalls = Array.isArray(response?.embeddingCalls)
? response.embeddingCalls
: [];
const sessions = Array.isArray(response?.sessions)
? response.sessions
: [];
const errors = Array.isArray(response?.errors) ? response.errors : [];
const totalCount = response?.totalCount ?? {
messages: messages.length,
llmCalls: llmCalls.length,
toolCalls: toolCalls.length,
embeddingCalls: embeddingCalls.length,
sessions: sessions.length,
errors: errors.length,
};
// Transform the response to match MonitoringData interface
const transformedData: MonitoringData = {
overview: {
totalMessages: response.overview.total_messages,
llmCalls: response.overview.llm_calls,
embeddingCalls: response.overview.embedding_calls || 0,
modelCalls:
response.overview.model_calls || response.overview.llm_calls,
successRate: response.overview.success_rate,
activeSessions: response.overview.active_sessions,
totalMessages: overview.total_messages,
llmCalls: overview.llm_calls,
embeddingCalls: overview.embedding_calls || 0,
modelCalls: overview.model_calls || overview.llm_calls,
successRate: overview.success_rate,
activeSessions: overview.active_sessions,
},
messages: response.messages.map(
messages: messages.map(
(msg: {
id: string;
timestamp: string;
@@ -117,8 +149,10 @@ export function useMonitoringData(filterState: FilterState) {
level: string;
platform?: string;
user_id?: string;
user_name?: string;
runner_name?: string;
variables?: string;
role?: string;
}) => ({
id: msg.id,
timestamp: parseUTCTimestamp(msg.timestamp),
@@ -132,11 +166,13 @@ export function useMonitoringData(filterState: FilterState) {
level: msg.level as 'info' | 'warning' | 'error' | 'debug',
platform: msg.platform,
userId: msg.user_id,
userName: msg.user_name,
runnerName: msg.runner_name,
variables: msg.variables,
role: msg.role,
}),
),
llmCalls: response.llmCalls.map(
llmCalls: llmCalls.map(
(call: {
id: string;
timestamp: string;
@@ -151,6 +187,7 @@ export function useMonitoringData(filterState: FilterState) {
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
error_message?: string;
message_id?: string;
}) => ({
@@ -169,11 +206,47 @@ export function useMonitoringData(filterState: FilterState) {
botName: call.bot_name,
pipelineId: call.pipeline_id,
pipelineName: call.pipeline_name,
sessionId: call.session_id,
errorMessage: call.error_message,
messageId: call.message_id,
}),
),
embeddingCalls: (response.embeddingCalls || []).map(
toolCalls: toolCalls.map(
(call: {
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
message_id?: string;
arguments?: string;
result?: string;
error_message?: string;
}) => ({
id: call.id,
timestamp: parseUTCTimestamp(call.timestamp),
toolName: call.tool_name,
toolSource: call.tool_source,
duration: call.duration,
status: call.status as 'success' | 'error',
botId: call.bot_id,
botName: call.bot_name,
pipelineId: call.pipeline_id,
pipelineName: call.pipeline_name,
sessionId: call.session_id,
messageId: call.message_id,
arguments: call.arguments,
result: call.result,
errorMessage: call.error_message,
}),
),
embeddingCalls: embeddingCalls.map(
(call: {
id: string;
timestamp: string;
@@ -208,7 +281,7 @@ export function useMonitoringData(filterState: FilterState) {
),
// Create merged modelCalls array from llmCalls and embeddingCalls
modelCalls: [] as ModelCall[], // Will be populated after transform
sessions: response.sessions.map(
sessions: sessions.map(
(session: {
session_id: string;
bot_id: string;
@@ -236,7 +309,7 @@ export function useMonitoringData(filterState: FilterState) {
userId: session.user_id,
}),
),
errors: response.errors.map(
errors: errors.map(
(error: {
id: string;
timestamp: string;
@@ -264,11 +337,12 @@ export function useMonitoringData(filterState: FilterState) {
}),
),
totalCount: {
messages: response.totalCount.messages,
llmCalls: response.totalCount.llmCalls,
embeddingCalls: response.totalCount.embeddingCalls || 0,
sessions: response.totalCount.sessions,
errors: response.totalCount.errors,
messages: totalCount.messages,
llmCalls: totalCount.llmCalls,
toolCalls: totalCount.toolCalls ?? toolCalls.length,
embeddingCalls: totalCount.embeddingCalls || 0,
sessions: totalCount.sessions,
errors: totalCount.errors,
},
};
@@ -289,6 +363,7 @@ export function useMonitoringData(filterState: FilterState) {
botName: call.botName,
pipelineId: call.pipelineId,
pipelineName: call.pipelineName,
sessionId: call.sessionId,
}),
);
+84 -313
View File
@@ -2,67 +2,28 @@ 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 } from 'lucide-react';
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 { ConversationTurnList } from './components/ConversationTurnList';
import { FeedbackStatsCards } from './components/FeedbackCard';
import { FeedbackList } from './components/FeedbackList';
import { MessageDetails } from './types/monitoring';
import { httpClient } from '@/app/infra/http/HttpClient';
import { buildConversationTurns } from './utils/conversationTurns';
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 } =
@@ -137,115 +98,37 @@ function MonitoringPageContent() {
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>>(
{},
const conversationTurns = useMemo(
() =>
buildConversationTurns(
data?.messages || [],
data?.llmCalls || [],
data?.errors || [],
data?.toolCalls || [],
),
[data?.messages, data?.llmCalls, data?.errors, data?.toolCalls],
);
// State for expanded errors
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [expandedTurnId, setExpandedTurnId] = 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) => {
const jumpToMessage = (messageId: string) => {
setActiveTab('messages');
// Small delay to ensure tab switch completes
setTimeout(() => {
toggleMessageExpand(messageId);
const turn = conversationTurns.find((item) =>
item.messages.some((message) => message.id === messageId),
);
setExpandedTurnId(turn?.id ?? 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 toggleTurnExpand = (turnId: string) => {
setExpandedTurnId((current) => (current === turnId ? null : turnId));
};
const toggleErrorExpand = (errorId: string) => {
@@ -259,9 +142,9 @@ function MonitoringPageContent() {
return (
<div className="w-full h-full overflow-y-auto overflow-x-hidden">
{/* Filters and Refresh Button - Sticky */}
<div className="sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-background">
<div className="ml-[2rem] mr-[1.5rem] px-[0.8rem]">
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-card rounded-xl border">
<div className="sticky top-0 z-10 -mt-1 pb-5 pt-1 bg-background">
<div>
<div className="flex flex-col gap-3 p-3 bg-card rounded-xl border sm:flex-row sm:flex-wrap sm:items-center sm:justify-between sm:gap-4 sm:p-4">
<MonitoringFilters
selectedBots={filterState.selectedBots}
selectedPipelines={filterState.selectedPipelines}
@@ -276,16 +159,9 @@ function MonitoringPageContent() {
variant="outline"
size="sm"
onClick={handleRefresh}
className="shadow-sm flex-shrink-0"
className="flex-1 shadow-sm sm:flex-shrink-0 sm:flex-none"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path>
</svg>
<RefreshCw className="w-4 h-4 mr-2" />
{t('monitoring.refreshData')}
</Button>
</div>
@@ -294,7 +170,7 @@ function MonitoringPageContent() {
</div>
{/* Content Area */}
<div className="flex flex-col gap-6 px-[0.8rem] pb-4">
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
{/* Overview Section */}
<OverviewCards
metrics={data?.overview || null}
@@ -310,24 +186,27 @@ function MonitoringPageContent() {
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">
<div className="px-3 pt-4 sm:px-6">
<TabsList className="h-12 w-full justify-start gap-1 overflow-x-auto p-1 sm:w-auto">
<TabsTrigger value="messages" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.messages')}
</TabsTrigger>
<TabsTrigger value="modelCalls" className="px-6 py-2">
<TabsTrigger value="modelCalls" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.modelCalls')}
</TabsTrigger>
<TabsTrigger value="feedback" className="px-6 py-2">
<TabsTrigger value="tokens" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.tokens')}
</TabsTrigger>
<TabsTrigger value="feedback" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.feedback')}
</TabsTrigger>
<TabsTrigger value="errors" className="px-6 py-2">
<TabsTrigger value="errors" className="px-3 py-2 sm:px-6">
{t('monitoring.tabs.errors')}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="messages" className="p-6 m-0">
<TabsContent value="messages" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
@@ -337,138 +216,26 @@ function MonitoringPageContent() {
</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>
{!loading && data && conversationTurns.length > 0 && (
<ConversationTurnList
turns={conversationTurns}
expandedTurnId={expandedTurnId}
onToggleTurn={toggleTurnExpand}
/>
)}
{/* 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>
))}
{!loading && (!data || conversationTurns.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>
)}
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM4 18.3851L5.76282 17H20V5H4V18.3851Z"></path>
</svg>
<div className="text-sm">
{t('monitoring.messageList.noMessages')}
</div>
</div>
)}
</div>
)}
</div>
</TabsContent>
<TabsContent value="modelCalls" className="p-6 m-0">
<TabsContent value="modelCalls" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
@@ -484,7 +251,7 @@ function MonitoringPageContent() {
{data.modelCalls.map((call) => (
<div
key={call.id}
className="border rounded-xl p-5 transition-all duration-200"
className="border rounded-xl p-3 transition-all duration-200 sm:p-5"
>
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
@@ -665,14 +432,7 @@ function MonitoringPageContent() {
!data.modelCalls ||
data.modelCalls.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899Z"></path>
</svg>
<Sparkles className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.modelCalls.noData')}
</div>
@@ -681,7 +441,25 @@ function MonitoringPageContent() {
</div>
</TabsContent>
<TabsContent value="feedback" className="p-6 m-0">
<TabsContent value="tokens" className="p-3 m-0 sm:p-6">
<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-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
@@ -713,7 +491,7 @@ function MonitoringPageContent() {
</div>
</TabsContent>
<TabsContent value="errors" className="p-6 m-0">
<TabsContent value="errors" className="p-3 m-0 sm:p-6">
<div>
{loading && (
<div className="py-12 flex justify-center">
@@ -730,7 +508,7 @@ function MonitoringPageContent() {
>
{/* 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"
className="p-3 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30 sm:p-5"
onClick={() => toggleErrorExpand(error.id)}
>
<div className="flex items-start justify-between">
@@ -867,14 +645,7 @@ function MonitoringPageContent() {
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<svg
className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11.0026 16L6.75999 11.7574L8.17421 10.3431L11.0026 13.1716L16.6595 7.51472L18.0737 8.92893L11.0026 16Z"></path>
</svg>
<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>
@@ -11,8 +11,10 @@ export interface MonitoringMessage {
level: 'info' | 'warning' | 'error' | 'debug';
platform?: string;
userId?: string;
userName?: string;
runnerName?: string;
variables?: string;
role?: 'user' | 'assistant' | string;
}
export interface LLMCall {
@@ -31,10 +33,29 @@ export interface LLMCall {
botName: string;
pipelineId: string;
pipelineName: string;
sessionId?: string;
errorMessage?: string;
messageId?: string;
}
export interface ToolCall {
id: string;
timestamp: Date;
toolName: string;
toolSource: 'native' | 'plugin' | 'mcp' | 'skill' | string;
duration: number;
status: 'success' | 'error';
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
sessionId?: string;
messageId?: string;
arguments?: string;
result?: string;
errorMessage?: string;
}
export interface EmbeddingCall {
id: string;
timestamp: Date;
@@ -199,6 +220,7 @@ export interface MonitoringData {
overview: OverviewMetrics;
messages: MonitoringMessage[];
llmCalls: LLMCall[];
toolCalls: ToolCall[];
embeddingCalls: EmbeddingCall[];
modelCalls: ModelCall[];
sessions: SessionInfo[];
@@ -208,6 +230,7 @@ export interface MonitoringData {
totalCount: {
messages: number;
llmCalls: number;
toolCalls?: number;
embeddingCalls: number;
sessions: number;
errors: number;
@@ -0,0 +1,294 @@
import {
ErrorLog,
LLMCall,
MonitoringMessage,
ToolCall,
} from '../types/monitoring';
type MessageRole = 'user' | 'assistant' | 'unknown';
export interface ConversationTurn {
id: string;
sessionId: string;
startedAt: Date;
lastActivityAt: Date;
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
runnerName?: string;
platform?: string;
userId?: string;
userName?: string;
userMessage?: MonitoringMessage;
assistantMessages: MonitoringMessage[];
messages: MonitoringMessage[];
llmCalls: LLMCall[];
toolCalls: ToolCall[];
errors: ErrorLog[];
status: 'success' | 'error' | 'pending';
level: 'info' | 'warning' | 'error' | 'debug';
inputTokens: number;
outputTokens: number;
totalTokens: number;
totalDuration: number;
totalToolDuration: number;
}
function normalizeRole(
message: MonitoringMessage,
llmMessageIds: Set<string>,
): MessageRole {
const role = message.role?.toLowerCase();
if (role === 'user' || role === 'assistant') {
return role;
}
if (llmMessageIds.has(message.id)) {
return 'user';
}
return 'unknown';
}
export function hasRenderableMessageContent(content?: string): boolean {
const trimmed = content?.trim();
if (!trimmed || trimmed === '[]' || trimmed === '""') {
return false;
}
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'string') {
return parsed.trim().length > 0;
}
if (Array.isArray(parsed)) {
return parsed.some(
(component) =>
typeof component !== 'object' ||
component === null ||
component.type !== 'Source',
);
}
} catch {
return true;
}
return true;
}
function createTurn(message: MonitoringMessage): ConversationTurn {
return {
id: message.id,
sessionId: message.sessionId,
startedAt: message.timestamp,
lastActivityAt: message.timestamp,
botId: message.botId,
botName: message.botName,
pipelineId: message.pipelineId,
pipelineName: message.pipelineName,
runnerName: message.runnerName,
platform: message.platform,
userId: message.userId,
userName: message.userName,
assistantMessages: [],
messages: [],
llmCalls: [],
toolCalls: [],
errors: [],
status: message.status,
level: message.level,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
totalDuration: 0,
totalToolDuration: 0,
};
}
function updateTurnActivity(turn: ConversationTurn, timestamp: Date) {
if (timestamp.getTime() > turn.lastActivityAt.getTime()) {
turn.lastActivityAt = timestamp;
}
}
function addMessageToTurn(
turn: ConversationTurn,
message: MonitoringMessage,
role: MessageRole,
) {
turn.messages.push(message);
updateTurnActivity(turn, message.timestamp);
if (message.level === 'error') {
turn.level = 'error';
} else if (message.level === 'warning' && turn.level !== 'error') {
turn.level = 'warning';
}
if (message.status === 'error') {
turn.status = 'error';
} else if (message.status === 'pending' && turn.status !== 'error') {
turn.status = 'pending';
}
if (role === 'assistant') {
turn.assistantMessages.push(message);
return;
}
if (!turn.userMessage) {
turn.userMessage = message;
turn.userId = message.userId ?? turn.userId;
turn.userName = message.userName ?? turn.userName;
return;
}
turn.assistantMessages.push(message);
}
function findTurnBySessionTime(
sessionTurns: Map<string, ConversationTurn[]>,
sessionId: string | undefined,
timestamp: Date,
): ConversationTurn | undefined {
if (!sessionId) {
return undefined;
}
const turns = sessionTurns.get(sessionId);
if (!turns?.length) {
return undefined;
}
let nearest = turns[0];
const targetTime = timestamp.getTime();
for (const turn of turns) {
if (turn.startedAt.getTime() <= targetTime) {
nearest = turn;
} else {
break;
}
}
return nearest;
}
export function buildConversationTurns(
messages: MonitoringMessage[],
llmCalls: LLMCall[],
errors: ErrorLog[],
toolCalls: ToolCall[] = [],
): ConversationTurn[] {
const activityMessageIds = new Set([
...llmCalls
.map((call) => call.messageId)
.filter((messageId): messageId is string => Boolean(messageId)),
...toolCalls
.map((call) => call.messageId)
.filter((messageId): messageId is string => Boolean(messageId)),
]);
const visibleMessages = messages
.filter((message) => hasRenderableMessageContent(message.messageContent))
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
const sessionTurns = new Map<string, ConversationTurn[]>();
const lastTurnBySession = new Map<string, ConversationTurn>();
const messageIdToTurn = new Map<string, ConversationTurn>();
for (const message of visibleMessages) {
const role = normalizeRole(message, activityMessageIds);
const previousTurn = lastTurnBySession.get(message.sessionId);
const shouldStartTurn = role === 'user' || !previousTurn;
const turn = shouldStartTurn ? createTurn(message) : previousTurn;
if (shouldStartTurn) {
const turns = sessionTurns.get(message.sessionId) ?? [];
turns.push(turn);
sessionTurns.set(message.sessionId, turns);
lastTurnBySession.set(message.sessionId, turn);
}
addMessageToTurn(turn, message, role);
messageIdToTurn.set(message.id, turn);
}
const allTurns = Array.from(sessionTurns.values()).flat();
for (const call of llmCalls) {
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
if (!turn) {
continue;
}
turn.llmCalls.push(call);
turn.inputTokens += call.tokens.input;
turn.outputTokens += call.tokens.output;
turn.totalTokens += call.tokens.total;
turn.totalDuration += call.duration;
updateTurnActivity(turn, call.timestamp);
if (call.status === 'error') {
turn.status = 'error';
turn.level = 'error';
}
}
for (const call of toolCalls) {
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
if (!turn) {
continue;
}
turn.toolCalls.push(call);
turn.totalToolDuration += call.duration;
updateTurnActivity(turn, call.timestamp);
if (call.status === 'error') {
turn.status = 'error';
turn.level = 'error';
}
}
for (const error of errors) {
const turn =
(error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp);
if (!turn) {
continue;
}
turn.errors.push(error);
turn.status = 'error';
turn.level = 'error';
updateTurnActivity(turn, error.timestamp);
}
for (const turn of allTurns) {
turn.messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
turn.assistantMessages.sort(
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
turn.llmCalls.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
turn.toolCalls.sort(
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
turn.errors.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}
return allTurns.sort(
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime(),
);
}
@@ -15,6 +15,7 @@ import {
At,
Quote,
Voice,
File as FileComponent,
Source,
} from '@/app/infra/entities/message';
import { toast } from 'sonner';
@@ -64,7 +65,12 @@ export default function DebugDialog({
const [isHovering, setIsHovering] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [selectedImages, setSelectedImages] = useState<
Array<{ file: File; preview: string; fileKey?: string }>
Array<{
file: File;
preview: string;
fileKey?: string;
kind: 'image' | 'voice' | 'file';
}>
>([]);
const [isUploading, setIsUploading] = useState(false);
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
@@ -292,23 +298,38 @@ export default function DebugDialog({
const files = e.target.files;
if (!files || files.length === 0) return;
const newImages: Array<{ file: File; preview: string }> = [];
const newImages: Array<{
file: File;
preview: string;
kind: 'image' | 'voice' | 'file';
}> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
const preview = URL.createObjectURL(file);
newImages.push({ file, preview });
newImages.push({
file,
preview: URL.createObjectURL(file),
kind: 'image',
});
} else if (file.type.startsWith('audio/')) {
newImages.push({ file, preview: '', kind: 'voice' });
} else {
newImages.push({ file, preview: '', kind: 'file' });
}
}
setSelectedImages((prev) => [...prev, ...newImages]);
// reset the input so selecting the same file again re-triggers onChange
e.target.value = '';
};
const handleRemoveImage = (index: number) => {
setSelectedImages((prev) => {
const newImages = [...prev];
URL.revokeObjectURL(newImages[index].preview);
if (newImages[index].preview) {
URL.revokeObjectURL(newImages[index].preview);
}
newImages.splice(index, 1);
return newImages;
});
@@ -372,19 +393,33 @@ export default function DebugDialog({
});
}
// Upload images and add to message chain
for (const image of selectedImages) {
// Upload attachments and add to message chain
for (const attachment of selectedImages) {
try {
const result = await httpClient.uploadWebSocketImage(
selectedPipelineId,
image.file,
);
messageChain.push({
type: 'Image',
path: result.file_key,
});
if (attachment.kind === 'image') {
const result = await httpClient.uploadWebSocketImage(
selectedPipelineId,
attachment.file,
);
messageChain.push({
type: 'Image',
path: result.file_key,
});
} else {
// Voice / File go through the generic document upload endpoint,
// which returns a storage key the backend resolves into the
// sandbox inbox just like images.
const result = await httpClient.uploadDocumentFile(attachment.file);
messageChain.push({
type: attachment.kind === 'voice' ? 'Voice' : 'File',
path: result.file_id,
...(attachment.kind === 'file'
? { name: attachment.file.name }
: {}),
});
}
} catch (error) {
console.error('Image upload failed:', error);
console.error('Attachment upload failed:', error);
toast.error(t('pipelines.debugDialog.imageUploadFailed'));
}
}
@@ -393,7 +428,9 @@ export default function DebugDialog({
setInputValue('');
setHasAt(false);
setQuotedMessage(null);
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
selectedImages.forEach((img) => {
if (img.preview) URL.revokeObjectURL(img.preview);
});
setSelectedImages([]);
// Send message via WebSocket
@@ -460,13 +497,29 @@ export default function DebugDialog({
}
case 'File': {
const file = component as MessageChainComponent & { name?: string };
const file = component as FileComponent;
const downloadHref = file.base64
? file.base64.startsWith('data:')
? file.base64
: `data:application/octet-stream;base64,${file.base64}`
: file.url || '';
const fileName = file.name || 'Unknown';
return (
<div key={index} className="my-2 flex items-center gap-2 text-sm">
<Paperclip className="size-4" />
<span>
[{t('pipelines.debugDialog.file')}] {file.name || 'Unknown'}
</span>
{downloadHref ? (
<a
href={downloadHref}
download={fileName}
className="text-primary underline hover:opacity-80"
>
[{t('pipelines.debugDialog.file')}] {fileName}
</a>
) : (
<span>
[{t('pipelines.debugDialog.file')}] {fileName}
</span>
)}
</div>
);
}
@@ -844,17 +897,30 @@ export default function DebugDialog({
</div>
)}
{/* Image preview area */}
{/* Attachment preview area */}
{selectedImages.length > 0 && (
<div className="px-4 pb-2">
<div className="flex gap-2 flex-wrap">
{selectedImages.map((image, index) => (
<div key={index} className="relative group">
<img
src={image.preview}
alt={`preview-${index}`}
className="w-20 h-20 object-cover rounded-lg border"
/>
{image.kind === 'image' ? (
<img
src={image.preview}
alt={`preview-${index}`}
className="w-20 h-20 object-cover rounded-lg border"
/>
) : (
<div className="w-36 h-20 px-2 rounded-lg border bg-muted/40 flex items-center gap-2 overflow-hidden">
{image.kind === 'voice' ? (
<Music className="size-5 shrink-0 text-muted-foreground" />
) : (
<Paperclip className="size-5 shrink-0 text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground truncate">
{image.file.name}
</span>
</div>
)}
<button
type="button"
onClick={() => handleRemoveImage(index)}
@@ -883,7 +949,7 @@ export default function DebugDialog({
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept="image/*,audio/*,*/*"
multiple
onChange={handleImageSelect}
className="hidden"
@@ -1,4 +1,5 @@
import React from 'react';
import { X } from 'lucide-react';
interface ImagePreviewDialogProps {
open: boolean;
@@ -28,19 +29,7 @@ export default function ImagePreviewDialog({
onClick={onClose}
className="self-end w-9 h-9 rounded-full bg-white hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 shadow-lg transition-all hover:scale-105 flex items-center justify-center"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<X className="w-4 h-4" />
</button>
{/* 图片 */}
@@ -2,65 +2,25 @@ import React, { useState, useMemo } 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 } from 'lucide-react';
import {
ChevronRight,
ChevronDown,
ExternalLink,
RefreshCw,
MessageCircle,
CheckCircle2,
Monitor,
} from 'lucide-react';
import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer';
import { ConversationTurnList } from '@/app/home/monitoring/components/ConversationTurnList';
import { buildConversationTurns } from '@/app/home/monitoring/utils/conversationTurns';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { httpClient } from '@/app/infra/http/HttpClient';
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
interface PipelineMonitoringTabProps {
pipelineId: string;
onNavigateToMonitoring?: () => void;
}
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;
}
export default function PipelineMonitoringTab({
pipelineId,
onNavigateToMonitoring,
@@ -80,98 +40,24 @@ export default function PipelineMonitoringTab({
const { data, loading, refetch } = useMonitoringData(filterState);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const [messageDetails, setMessageDetails] = useState<
Record<string, MessageDetails>
>({});
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
{},
const conversationTurns = useMemo(
() =>
data
? buildConversationTurns(
data.messages,
data.llmCalls,
data.errors,
data.toolCalls,
)
: [],
[data],
);
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('messages');
const toggleMessageExpand = async (messageId: string) => {
if (expandedMessageId === messageId) {
setExpandedMessageId(null);
} else {
setExpandedMessageId(messageId);
if (!messageDetails[messageId]) {
setLoadingDetails((prev) => ({ ...prev, [messageId]: true }));
try {
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: parseUTCTimestamp(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: parseUTCTimestamp(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: parseUTCTimestamp(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((prev) => ({ ...prev, [messageId]: false }));
}
}
}
const toggleTurnExpand = (turnId: string) => {
setExpandedTurnId((current) => (current === turnId ? null : turnId));
};
const toggleErrorExpand = (errorId: string) => {
@@ -182,12 +68,16 @@ export default function PipelineMonitoringTab({
}
};
const jumpToMessage = async (messageId: string) => {
const jumpToMessage = (messageId: string) => {
setActiveTab('messages');
// Small delay to ensure tab transition completes before expanding
setTimeout(() => {
toggleMessageExpand(messageId);
}, 100);
const turn = conversationTurns.find((item) =>
item.messages.some((message) => message.id === messageId),
);
if (turn) {
setExpandedTurnId(turn.id);
}
};
return (
@@ -205,14 +95,7 @@ export default function PipelineMonitoringTab({
onClick={onNavigateToMonitoring}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19V6.413L11.2071 14.2071L9.79289 12.7929L17.585 5H13V3H21Z"></path>
</svg>
<ExternalLink className="w-4 h-4 mr-2" />
{t('pipelines.monitoring.detailedLogs')}
</Button>
)}
@@ -222,14 +105,7 @@ export default function PipelineMonitoringTab({
onClick={refetch}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path>
</svg>
<RefreshCw className="w-4 h-4 mr-2" />
{t('monitoring.refreshData')}
</Button>
</div>
@@ -301,154 +177,22 @@ export default function PipelineMonitoringTab({
</div>
)}
{!loading && data && data.messages && data.messages.length > 0 && (
<div className="space-y-3">
{data.messages
.filter((msg) => {
const content = msg.messageContent?.trim();
return content && content !== '[]' && content !== '""';
})
.map((msg) => (
<div
key={msg.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200"
>
<div
className="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
onClick={() => toggleMessageExpand(msg.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
<div className="mr-2 mt-0.5">
{expandedMessageId === msg.id ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-xs px-2 py-0.5 rounded ${
msg.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: msg.status === 'error'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}
>
{msg.status}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{msg.botName}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
<MessageContentRenderer
content={msg.messageContent}
/>
</div>
</div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{msg.timestamp.toLocaleString()}
</span>
</div>
</div>
{expandedMessageId === msg.id && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
{loadingDetails[msg.id] && (
<div className="flex justify-center py-8">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
</div>
)}
{!loadingDetails[msg.id] &&
messageDetails[msg.id] && (
<div className="space-y-4">
{messageDetails[msg.id].errors.length > 0 && (
<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-2">
{t('monitoring.errors.errorMessage')}
</h4>
{messageDetails[msg.id].errors.map(
(error) => (
<div
key={error.id}
className="text-sm space-y-2"
>
<div className="text-red-600 dark:text-red-400">
{error.errorType}:{' '}
{error.errorMessage}
</div>
{error.stackTrace && (
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-40 bg-white dark:bg-gray-900 p-2 rounded whitespace-pre-wrap break-words">
{error.stackTrace}
</pre>
)}
</div>
),
)}
</div>
)}
{messageDetails[msg.id].llmCalls.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-2">
{t('monitoring.tabs.modelCalls')} (
{messageDetails[msg.id].llmCalls.length})
</h4>
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{
messageDetails[msg.id].llmStats
.totalTokens
}
</div>
<div>
{t('monitoring.llmCalls.duration')}:{' '}
{messageDetails[
msg.id
].llmStats.totalDurationMs.toFixed(0)}
ms
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
{!loading && data && conversationTurns.length > 0 && (
<ConversationTurnList
turns={conversationTurns}
expandedTurnId={expandedTurnId}
onToggleTurn={toggleTurnExpand}
/>
)}
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p className="text-base font-medium">
{t('monitoring.messageList.noMessages')}
</p>
</div>
)}
{!loading && (!data || conversationTurns.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium">
{t('monitoring.messageList.noMessages')}
</p>
</div>
)}
</TabsContent>
{/* Errors Tab */}
@@ -543,19 +287,7 @@ export default function PipelineMonitoringTab({
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<CheckCircle2 className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600" />
<p className="text-base font-medium text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
</p>
@@ -638,19 +370,7 @@ export default function PipelineMonitoringTab({
{!loading &&
(!data || !data.llmCalls || data.llmCalls.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<Monitor className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium">
{t('monitoring.llmCalls.noData')}
</p>
@@ -1,6 +1,7 @@
import styles from './pipelineCard.module.css';
import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';
import { useTranslation } from 'react-i18next';
import { Clock, Star } from 'lucide-react';
export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
const { t } = useTranslation();
@@ -21,14 +22,7 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
</div>
<div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>
<svg
className={`${styles.basicInfoUpdateTimeIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z"></path>
</svg>
<Clock className={`${styles.basicInfoUpdateTimeIcon}`} />
<div className={`${styles.basicInfoUpdateTimeText}`}>
{t('pipelines.updateTime')}
{cardVO.lastUpdatedTimeAgo}
@@ -39,14 +33,7 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
{cardVO.isDefault && (
<div className={styles.operationContainer}>
<div className={styles.operationDefaultBadge}>
<svg
className={styles.operationDefaultBadgeIcon}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12.0006 18.26L4.94715 22.2082L6.52248 14.2799L0.587891 8.7918L8.61493 7.84006L12.0006 0.5L15.3862 7.84006L23.4132 8.7918L17.4787 14.2799L19.054 22.2082L12.0006 18.26Z"></path>
</svg>
<Star className={styles.operationDefaultBadgeIcon} />
<div className={styles.operationDefaultBadgeText}>
{t('pipelines.defaultBadge')}
</div>
@@ -12,13 +12,39 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Plus, X, Server, Wrench } from 'lucide-react';
import { CircleHelp, Plus, X, Server, Wrench, Sparkles } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Plugin } from '@/app/infra/entities/plugin';
import { MCPServer } from '@/app/infra/entities/api';
import { MCPServer, Skill } from '@/app/infra/entities/api';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
function InfoTooltip({ label }: { label: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-4 items-center justify-center rounded-full text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
aria-label={label}
>
<CircleHelp className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[280px]">
{label}
</TooltipContent>
</Tooltip>
);
}
export default function PipelineExtension({
pipelineId,
@@ -26,19 +52,31 @@ export default function PipelineExtension({
pipelineId: string;
}) {
const { t } = useTranslation();
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
const [loading, setLoading] = useState(true);
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
const [enableAllSkills, setEnableAllSkills] = useState(true);
const [selectedPlugins, setSelectedPlugins] = useState<Plugin[]>([]);
const [allPlugins, setAllPlugins] = useState<Plugin[]>([]);
const [selectedMCPServers, setSelectedMCPServers] = useState<MCPServer[]>([]);
const [allMCPServers, setAllMCPServers] = useState<MCPServer[]>([]);
const [selectedSkills, setSelectedSkills] = useState<Skill[]>([]);
const [allSkills, setAllSkills] = useState<Skill[]>([]);
const [pluginDialogOpen, setPluginDialogOpen] = useState(false);
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
const [skillDialogOpen, setSkillDialogOpen] = useState(false);
const [tempSelectedPluginIds, setTempSelectedPluginIds] = useState<string[]>(
[],
);
const [tempSelectedMCPIds, setTempSelectedMCPIds] = useState<string[]>([]);
const [tempSelectedSkillIds, setTempSelectedSkillIds] = useState<string[]>(
[],
);
useEffect(() => {
loadExtensions();
@@ -57,6 +95,7 @@ export default function PipelineExtension({
setEnableAllPlugins(data.enable_all_plugins ?? true);
setEnableAllMCPServers(data.enable_all_mcp_servers ?? true);
setEnableAllSkills(data.enable_all_skills ?? true);
const boundPluginIds = new Set(
data.bound_plugins.map((p) => `${p.author}/${p.name}`),
@@ -77,6 +116,15 @@ export default function PipelineExtension({
setSelectedMCPServers(selectedMCP);
setAllMCPServers(data.available_mcp_servers);
// Load Skills
const boundSkillNames = new Set(data.bound_skills || []);
const selectedSkill = (data.available_skills || []).filter((skill) =>
boundSkillNames.has(skill.name),
);
setSelectedSkills(selectedSkill);
setAllSkills(data.available_skills || []);
} catch (error) {
console.error('Failed to load extensions:', error);
toast.error(t('pipelines.extensions.loadError'));
@@ -88,8 +136,10 @@ export default function PipelineExtension({
const saveToBackend = async (
plugins: Plugin[],
mcpServers: MCPServer[],
skills: Skill[],
newEnableAllPlugins?: boolean,
newEnableAllMCPServers?: boolean,
newEnableAllSkills?: boolean,
) => {
try {
const boundPluginsArray = plugins.map((plugin) => {
@@ -101,6 +151,7 @@ export default function PipelineExtension({
});
const boundMCPServerIds = mcpServers.map((server) => server.uuid || '');
const boundSkillIds = skills.map((skill) => skill.name);
await backendClient.updatePipelineExtensions(
pipelineId,
@@ -108,6 +159,8 @@ export default function PipelineExtension({
boundMCPServerIds,
newEnableAllPlugins ?? enableAllPlugins,
newEnableAllMCPServers ?? enableAllMCPServers,
boundSkillIds,
newEnableAllSkills ?? enableAllSkills,
);
toast.success(t('pipelines.extensions.saveSuccess'));
} catch (error) {
@@ -123,13 +176,19 @@ export default function PipelineExtension({
(p) => getPluginId(p) !== pluginId,
);
setSelectedPlugins(newPlugins);
await saveToBackend(newPlugins, selectedMCPServers);
await saveToBackend(newPlugins, selectedMCPServers, selectedSkills);
};
const handleRemoveMCPServer = async (serverUuid: string) => {
const newServers = selectedMCPServers.filter((s) => s.uuid !== serverUuid);
setSelectedMCPServers(newServers);
await saveToBackend(selectedPlugins, newServers);
await saveToBackend(selectedPlugins, newServers, selectedSkills);
};
const handleRemoveSkill = async (skillName: string) => {
const newSkills = selectedSkills.filter((s) => s.name !== skillName);
setSelectedSkills(newSkills);
await saveToBackend(selectedPlugins, selectedMCPServers, newSkills);
};
const handleOpenPluginDialog = () => {
@@ -142,6 +201,11 @@ export default function PipelineExtension({
setMcpDialogOpen(true);
};
const handleOpenSkillDialog = () => {
setTempSelectedSkillIds(selectedSkills.map((s) => s.name));
setSkillDialogOpen(true);
};
const handleTogglePlugin = (pluginId: string) => {
setTempSelectedPluginIds((prev) =>
prev.includes(pluginId)
@@ -158,33 +222,45 @@ export default function PipelineExtension({
);
};
const handleToggleSkill = (skillName: string) => {
setTempSelectedSkillIds((prev) =>
prev.includes(skillName)
? prev.filter((id) => id !== skillName)
: [...prev, skillName],
);
};
const handleToggleAllPlugins = () => {
if (tempSelectedPluginIds.length === allPlugins.length) {
// Deselect all
setTempSelectedPluginIds([]);
} else {
// Select all
setTempSelectedPluginIds(allPlugins.map((p) => getPluginId(p)));
}
};
const handleToggleAllMCPServers = () => {
if (tempSelectedMCPIds.length === allMCPServers.length) {
// Deselect all
setTempSelectedMCPIds([]);
} else {
// Select all
setTempSelectedMCPIds(allMCPServers.map((s) => s.uuid || ''));
}
};
const handleToggleAllSkills = () => {
if (tempSelectedSkillIds.length === allSkills.length) {
setTempSelectedSkillIds([]);
} else {
setTempSelectedSkillIds(allSkills.map((s) => s.name));
}
};
const handleConfirmPluginSelection = async () => {
const newSelected = allPlugins.filter((p) =>
tempSelectedPluginIds.includes(getPluginId(p)),
);
setSelectedPlugins(newSelected);
setPluginDialogOpen(false);
await saveToBackend(newSelected, selectedMCPServers);
await saveToBackend(newSelected, selectedMCPServers, selectedSkills);
};
const handleConfirmMCPSelection = async () => {
@@ -193,7 +269,16 @@ export default function PipelineExtension({
);
setSelectedMCPServers(newSelected);
setMcpDialogOpen(false);
await saveToBackend(selectedPlugins, newSelected);
await saveToBackend(selectedPlugins, newSelected, selectedSkills);
};
const handleConfirmSkillSelection = async () => {
const newSelected = allSkills.filter((s) =>
tempSelectedSkillIds.includes(s.name),
);
setSelectedSkills(newSelected);
setSkillDialogOpen(false);
await saveToBackend(selectedPlugins, selectedMCPServers, newSelected);
};
const handleToggleEnableAllPlugins = async (checked: boolean) => {
@@ -201,8 +286,10 @@ export default function PipelineExtension({
await saveToBackend(
selectedPlugins,
selectedMCPServers,
selectedSkills,
checked,
undefined,
undefined,
);
};
@@ -211,6 +298,20 @@ export default function PipelineExtension({
await saveToBackend(
selectedPlugins,
selectedMCPServers,
selectedSkills,
undefined,
checked,
undefined,
);
};
const handleToggleEnableAllSkills = async (checked: boolean) => {
setEnableAllSkills(checked);
await saveToBackend(
selectedPlugins,
selectedMCPServers,
selectedSkills,
undefined,
undefined,
checked,
);
@@ -341,9 +442,14 @@ export default function PipelineExtension({
{/* MCP Servers Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.mcpServersTitle')}
</h3>
<div className="flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.mcpServersTitle')}
</h3>
<InfoTooltip
label={t('pipelines.extensions.mcpServersScopeTooltip')}
/>
</div>
<div className="flex items-center gap-2">
<Label
htmlFor="enable-all-mcp-servers"
@@ -351,6 +457,9 @@ export default function PipelineExtension({
>
{t('pipelines.extensions.enableAllMCPServers')}
</Label>
<InfoTooltip
label={t('pipelines.extensions.enableAllMCPServersTooltip')}
/>
<Switch
id="enable-all-mcp-servers"
checked={enableAllMCPServers}
@@ -432,6 +541,88 @@ export default function PipelineExtension({
</Button>
</div>
{/* Skills Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">
{t('pipelines.extensions.skillsTitle')}
</h3>
<div className="flex items-center gap-2">
<Label
htmlFor="enable-all-skills"
className="text-sm font-normal cursor-pointer"
>
{t('pipelines.extensions.enableAllSkills')}
</Label>
<Switch
id="enable-all-skills"
checked={enableAllSkills}
onCheckedChange={handleToggleEnableAllSkills}
disabled={!boxAvailable}
/>
</div>
</div>
{!boxAvailable && (
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
)}
<div className="space-y-2">
{enableAllSkills ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.allSkillsEnabled')}
</p>
</div>
) : selectedSkills.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noSkillsSelected')}
</p>
</div>
) : (
<div className="space-y-2">
{selectedSkills.map((skill) => (
<div
key={skill.name}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
>
<div className="flex-1 flex items-center gap-3">
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">
{skill.display_name || skill.name}
</div>
<div className="text-sm text-muted-foreground">
{skill.description}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveSkill(skill.name)}
disabled={!boxAvailable}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
<Button
onClick={handleOpenSkillDialog}
variant="outline"
className="w-full"
disabled={enableAllSkills || !boxAvailable}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addSkill')}
</Button>
</div>
{/* Plugin Selection Dialog */}
<Dialog open={pluginDialogOpen} onOpenChange={setPluginDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
@@ -620,6 +811,73 @@ export default function PipelineExtension({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Skill Selection Dialog */}
<Dialog open={skillDialogOpen} onOpenChange={setSkillDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{t('pipelines.extensions.selectSkills')}</DialogTitle>
</DialogHeader>
{allSkills.length > 0 && (
<div
className="flex items-center gap-3 px-1 py-2 border-b cursor-pointer"
onClick={handleToggleAllSkills}
>
<Checkbox
checked={
tempSelectedSkillIds.length === allSkills.length &&
allSkills.length > 0
}
onCheckedChange={handleToggleAllSkills}
/>
<span className="text-sm font-medium">
{t('pipelines.extensions.selectAll')}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
{allSkills.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{t('pipelines.extensions.noSkillsAvailable')}
</p>
</div>
) : (
allSkills.map((skill) => {
const isSelected = tempSelectedSkillIds.includes(skill.name);
return (
<div
key={skill.name}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => handleToggleSkill(skill.name)}
>
<Checkbox checked={isSelected} />
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
<Sparkles className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium">
{skill.display_name || skill.name}
</div>
<div className="text-sm text-muted-foreground">
{skill.description}
</div>
</div>
</div>
);
})
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSkillDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={handleConfirmSkillSelection}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -7,6 +7,8 @@ import {
} from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { systemInfo } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -75,6 +77,7 @@ export default function PipelineFormComponent({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
const { available: boxAvailable } = useBoxStatus();
const formSchema = isEditMode
? z.object({
@@ -185,6 +188,10 @@ export default function PipelineFormComponent({
if (!isEditMode || !savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [isEditMode, watchedValues]);
// Keep a ref so that non-reactive callbacks (handleDynamicFormEmit) can
// read the latest dirty state without stale closures.
const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
hasUnsavedChangesRef.current = hasUnsavedChanges;
// Notify parent when dirty state changes
useEffect(() => {
@@ -304,6 +311,9 @@ export default function PipelineFormComponent({
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
// However, if the form is already dirty (the user has made real changes),
// we must NOT re-capture the snapshot — otherwise we would silently absorb
// those real changes and flip hasUnsavedChanges back to false.
function handleDynamicFormEmit(
formName: keyof FormValues,
stageName: string,
@@ -313,7 +323,6 @@ export default function PipelineFormComponent({
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentValues =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.getValues(formName) as Record<string, any>) || {};
form.setValue(formName, {
...currentValues,
@@ -322,9 +331,14 @@ export default function PipelineFormComponent({
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
// Synchronously re-capture snapshot so that the useMemo comparison
// in the same render cycle still returns false.
savedSnapshotRef.current = JSON.stringify(form.getValues());
// Only re-capture the snapshot when the form has no other pending
// changes. If the user already modified something (e.g. switched
// runner), the snapshot must remain at the last-saved state so that
// hasUnsavedChanges stays true.
const currentSnapshot = JSON.stringify(form.getValues());
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
savedSnapshotRef.current = currentSnapshot;
}
}
}
@@ -353,7 +367,6 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
@@ -387,7 +400,6 @@ export default function PipelineFormComponent({
<N8nAuthFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
@@ -401,6 +413,61 @@ export default function PipelineFormComponent({
}
}
// Box availability is exposed through ``systemContext.__system.box_available``
// so individual yaml-driven fields (e.g. ``box-session-id-template``) can
// opt-in via ``disable_if`` + ``disabled_tooltip`` rather than every page
// hard-coding a banner. Field-level gating keeps unrelated fields
// untouched.
//
// ``box_scope_editable`` folds the two reasons the Sandbox Scope selector
// can be locked into a single flag the yaml ``disable_if`` consumes:
// 1. Box sandbox is unavailable, or
// 2. the deployment pins all pipelines to a fixed scope via
// ``system.limitation.force_box_session_id_template`` (SaaS).
const forcedBoxTemplate =
systemInfo.limitation?.force_box_session_id_template || '';
const boxScopeForced = !!forcedBoxTemplate;
const isLocalAgentStage = formName === 'ai' && stage.name === 'local-agent';
const stageSystemContext = isLocalAgentStage
? {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !boxScopeForced,
pipeline_id: pipelineId,
}
: undefined;
// When the deployment pins every pipeline to a fixed sandbox scope (SaaS
// ``force_box_session_id_template``), the Sandbox Scope selector is locked.
// The runtime already overrides the scope on every exec, but the stored
// pipeline value can be anything (e.g. the per-chat default), which would
// make the locked selector display a scope that is NOT the one actually in
// effect. Coerce the displayed/saved value to the forced template so the UI
// truthfully reflects runtime behavior.
const stageInitialValues: Record<string, any> =
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
const effectiveInitialValues =
isLocalAgentStage && boxScopeForced
? {
...stageInitialValues,
'box-session-id-template': forcedBoxTemplate,
}
: stageInitialValues;
const emitStageValues = (values: object) => {
if (!isLocalAgentStage) {
handleDynamicFormEmit(formName, stage.name, values);
return;
}
const latestStageValues =
((form.getValues(formName) as Record<string, any>) || {})[stage.name] ||
{};
handleDynamicFormEmit(formName, stage.name, {
...latestStageValues,
...values,
});
};
return (
<Card key={stage.name}>
<CardHeader>
@@ -414,13 +481,9 @@ export default function PipelineFormComponent({
<CardContent className="space-y-6">
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
initialValues={effectiveInitialValues}
onSubmit={emitStageValues}
systemContext={stageSystemContext}
/>
</CardContent>
</Card>
+279 -54
View File
@@ -1,10 +1,36 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
import PluginLogs from '@/app/home/plugins/components/plugin-installed/plugin-logs/PluginLogs';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Bug } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Plugin } from '@/app/infra/entities/plugin';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { Bug, Puzzle, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
/**
* Plugin detail page content.
@@ -12,7 +38,11 @@ import { Bug } from 'lucide-react';
*/
export default function PluginDetailContent({ id }: { id: string }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
const [pluginInfo, setPluginInfo] = useState<Plugin | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteData, setDeleteData] = useState(false);
// Parse "author/name" composite key
const slashIndex = id.indexOf('/');
@@ -20,6 +50,23 @@ export default function PluginDetailContent({ id }: { id: string }) {
const pluginName = slashIndex >= 0 ? id.substring(slashIndex + 1) : id;
const plugin = plugins.find((p) => p.id === id);
const title =
pluginInfo?.manifest.manifest.metadata.label &&
extractI18nObject(pluginInfo.manifest.manifest.metadata.label)
? extractI18nObject(pluginInfo.manifest.manifest.metadata.label)
: plugin?.name || `${pluginAuthor}/${pluginName}`;
const description = pluginInfo?.manifest.manifest.metadata.description
? extractI18nObject(pluginInfo.manifest.manifest.metadata.description)
: plugin?.description;
const asyncTask = useAsyncTask({
onSuccess: () => {
toast.success(t('plugins.deleteSuccess'));
setShowDeleteConfirm(false);
void refreshPlugins();
navigate('/home/extensions');
},
});
// Set breadcrumb entity name
useEffect(() => {
@@ -27,6 +74,18 @@ export default function PluginDetailContent({ id }: { id: string }) {
return () => setDetailEntityName(null);
}, [plugin, pluginAuthor, pluginName, setDetailEntityName]);
useEffect(() => {
let cancelled = false;
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
if (!cancelled) {
setPluginInfo(res.plugin);
}
});
return () => {
cancelled = true;
};
}, [pluginAuthor, pluginName]);
function handleFormSubmit(timeout?: number) {
if (timeout) {
setTimeout(() => {
@@ -37,60 +96,226 @@ export default function PluginDetailContent({ id }: { id: string }) {
}
}
function executeDelete() {
httpClient
.removePlugin(pluginAuthor, pluginName, deleteData)
.then((res) => {
asyncTask.startTask(res.task_id);
})
.catch((error) => {
toast.error(t('plugins.deleteError') + error.message);
});
}
const sourceBadge = plugin?.debug ? (
<Badge
variant="outline"
className="shrink-0 border-orange-400 text-[0.7rem] text-orange-400"
>
<Bug className="size-3.5" />
{t('plugins.debugging')}
</Badge>
) : plugin?.installSource === 'github' ? (
<Badge
variant="outline"
className="shrink-0 border-blue-400 text-[0.7rem] text-blue-400"
>
{t('plugins.fromGithub')}
</Badge>
) : plugin?.installSource === 'local' ? (
<Badge
variant="outline"
className="shrink-0 border-green-400 text-[0.7rem] text-green-400"
>
{t('plugins.fromLocal')}
</Badge>
) : plugin?.installSource === 'marketplace' ? (
<Badge
variant="outline"
className="shrink-0 border-purple-400 text-[0.7rem] text-purple-400"
>
{t('plugins.fromMarketplace')}
</Badge>
) : null;
const componentBadges = pluginInfo && (
<PluginComponentList
components={pluginInfo.components.reduce<Record<string, number>>(
(acc, component) => {
const kind = component.manifest.manifest.kind;
acc[kind] = (acc[kind] ?? 0) + 1;
return acc;
},
{},
)}
showComponentName
showTitle={false}
useBadge
t={t}
/>
);
const dangerZone = (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('plugins.dangerZone')}
</CardTitle>
<CardDescription>{t('plugins.dangerZoneDescription')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{t('plugins.deletePlugin')}</p>
<p className="text-sm text-muted-foreground">
{t('plugins.confirmDeletePlugin', {
author: pluginAuthor,
name: pluginName,
})}
</p>
</div>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
);
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-3 pb-4 shrink-0">
<h1 className="text-xl font-semibold">
{pluginAuthor}/{pluginName}
</h1>
{plugin?.debug ? (
<Badge
variant="outline"
className="text-[0.7rem] border-orange-400 text-orange-400"
>
<Bug className="size-3.5" />
{t('plugins.debugging')}
</Badge>
) : plugin?.installSource === 'github' ? (
<Badge
variant="outline"
className="text-[0.7rem] border-blue-400 text-blue-400"
>
{t('plugins.fromGithub')}
</Badge>
) : plugin?.installSource === 'local' ? (
<Badge
variant="outline"
className="text-[0.7rem] border-green-400 text-green-400"
>
{t('plugins.fromLocal')}
</Badge>
) : plugin?.installSource === 'marketplace' ? (
<Badge
variant="outline"
className="text-[0.7rem] border-purple-400 text-purple-400"
>
{t('plugins.fromMarketplace')}
</Badge>
) : null}
<>
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-col gap-2 pb-4">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<h1 className="truncate text-xl font-semibold">{title}</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Puzzle className="size-3.5" />
{t('market.typePlugin')}
</Badge>
{sourceBadge}
{componentBadges}
</div>
{description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
<div className="flex min-h-0 max-w-full flex-1 flex-col gap-6 overflow-y-auto md:flex-row md:overflow-hidden">
<div className="min-w-0 max-w-full space-y-4 pb-6 md:min-h-0 md:w-[380px] md:flex-shrink-0 md:overflow-y-auto md:overflow-x-hidden xl:w-[420px]">
<PluginForm
pluginAuthor={pluginAuthor}
pluginName={pluginName}
onFormSubmit={handleFormSubmit}
/>
{dangerZone}
</div>
<div className="hidden w-px shrink-0 bg-border md:block" />
<div className="flex min-w-0 flex-1 flex-col pb-6 md:min-h-0 md:overflow-hidden">
<Tabs defaultValue="docs" className="flex min-h-0 flex-1 flex-col">
<TabsList className="mb-2 shrink-0">
<TabsTrigger value="docs" className="flex-none px-4">
{t('plugins.tabDocs')}
</TabsTrigger>
<TabsTrigger value="logs" className="flex-none px-4">
{t('plugins.tabLogs')}
</TabsTrigger>
</TabsList>
<TabsContent
value="docs"
className="min-h-0 flex-1 md:overflow-y-auto md:overflow-x-hidden"
>
<PluginReadme
pluginAuthor={pluginAuthor}
pluginName={pluginName}
/>
</TabsContent>
<TabsContent
value="logs"
className="min-h-0 flex-1 md:overflow-hidden"
>
<PluginLogs
pluginAuthor={pluginAuthor}
pluginName={pluginName}
/>
</TabsContent>
</Tabs>
</div>
</div>
</div>
<div className="flex flex-1 flex-col md:flex-row overflow-hidden min-h-0 gap-6 max-w-full">
{/* Left side - Config */}
<div className="md:w-[380px] md:flex-shrink-0 overflow-y-auto overflow-x-hidden">
<PluginForm
pluginAuthor={pluginAuthor}
pluginName={pluginName}
onFormSubmit={handleFormSubmit}
/>
</div>
{/* Divider */}
<div className="hidden md:block w-px bg-border shrink-0" />
{/* Right side - Readme */}
<div className="flex-1 overflow-y-auto overflow-x-hidden min-w-0">
<PluginReadme pluginAuthor={pluginAuthor} pluginName={pluginName} />
</div>
</div>
</div>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('plugins.deleteConfirm')}</DialogTitle>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.RUNNING
? t('plugins.deleting')
: t('plugins.confirmDeletePlugin', {
author: pluginAuthor,
name: pluginName,
})}
</DialogDescription>
</DialogHeader>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div className="flex items-center space-x-2">
<Checkbox
id="delete-plugin-data"
checked={deleteData}
onCheckedChange={(checked) => setDeleteData(checked === true)}
/>
<label
htmlFor="delete-plugin-data"
className="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t('plugins.deleteDataCheckbox')}
</label>
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div className="text-sm text-destructive">{asyncTask.error}</div>
)}
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button variant="destructive" onClick={executeDelete}>
{t('common.confirmDelete')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button variant="destructive" disabled>
{t('plugins.deleting')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="outline"
onClick={() => {
setShowDeleteConfirm(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,203 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Archive, CheckCircle2, Loader2, Package } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
type PluginLocalPreview = Awaited<
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
>;
interface PluginLocalPreviewPanelProps {
file: File;
onInstallStarted?: () => void;
onCancel?: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
export default function PluginLocalPreviewPanel({
file,
onInstallStarted,
onCancel,
}: PluginLocalPreviewPanelProps) {
const { t } = useTranslation();
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
const [preview, setPreview] = useState<PluginLocalPreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const [installing, setInstalling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const loadPreview = useCallback(async () => {
setPreviewing(true);
setPreview(null);
setErrorMessage(null);
try {
const result = await httpClient.previewPluginInstallFromLocal(file);
setPreview(result);
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('plugins.localPreview.failed'));
} finally {
setPreviewing(false);
}
}, [file, t]);
useEffect(() => {
void loadPreview();
}, [loadPreview]);
async function handleInstall() {
setInstalling(true);
setErrorMessage(null);
try {
const resp = await httpClient.installPluginFromLocal(file);
const taskId = resp.task_id;
const taskKey = `local-${taskId}`;
const pluginName =
preview?.metadata.label && extractI18nObject(preview.metadata.label)
? extractI18nObject(preview.metadata.label)
: preview?.metadata.name || file.name;
addTask({
taskId,
pluginName,
source: 'local',
extensionType: 'plugin',
fileSize: file.size,
});
setSelectedTaskId(taskKey);
toast.success(t('plugins.installSuccess'));
onInstallStarted?.();
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('plugins.installFailed'));
} finally {
setInstalling(false);
}
}
const metadata = preview?.metadata;
const label = metadata?.label ? extractI18nObject(metadata.label) : '';
const description = metadata?.description
? extractI18nObject(metadata.description)
: '';
const componentCounts = preview?.component_counts || {};
return (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md bg-muted/40 px-3 py-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{previewing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Archive className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{previewing
? t('plugins.localPreview.unpacking')
: t('plugins.localPreview.unpackComplete')}
</div>
<div className="mt-1 break-all text-xs text-muted-foreground">
{file.name} · {formatFileSize(file.size)}
</div>
</div>
</div>
{preview && (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Package className="size-4" />
{t('plugins.localPreview.pluginInfo')}
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.name')}
</span>
<span className="truncate font-medium">
{label || metadata?.name || '-'}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.author')}
</span>
<span className="truncate">{metadata?.author || '-'}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">
{t('plugins.localPreview.version')}
</span>
<span>{metadata?.version || '-'}</span>
</div>
</div>
{description && (
<p className="text-sm leading-6 text-muted-foreground">
{description}
</p>
)}
<div className="flex flex-wrap items-center gap-2 text-sm">
<PluginComponentList
components={componentCounts}
showComponentName
showTitle
useBadge
t={t}
/>
</div>
</div>
)}
{preview && (
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-300">
<CheckCircle2 className="size-4" />
{t('plugins.localPreview.ready')}
</div>
)}
{errorMessage && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
{onCancel && (
<Button variant="outline" onClick={onCancel} disabled={installing}>
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={!preview || previewing || installing}
>
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
</Button>
</div>
</div>
);
}
@@ -10,8 +10,8 @@ import { Button } from '@/components/ui/button';
import {
Download,
Package,
Settings,
Rocket,
Server,
Sparkles,
CheckCircle2,
XCircle,
Loader2,
@@ -39,16 +39,6 @@ const STAGES: {
icon: Package,
i18nKey: 'plugins.installProgress.installingDeps',
},
{
key: InstallStage.INITIALIZING,
icon: Settings,
i18nKey: 'plugins.installProgress.initializing',
},
{
key: InstallStage.LAUNCHING,
icon: Rocket,
i18nKey: 'plugins.installProgress.launching',
},
];
function getStageIndex(stage: InstallStage): number {
@@ -183,6 +173,15 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
const isDone = task.stage === InstallStage.DONE;
const isError = task.stage === InstallStage.ERROR;
// MCP / Skill don't have the plugin's download + dependency-install stages;
// show a single "installing → done/failed" row instead of plugin steps.
const isPlugin = task.extensionType === 'plugin';
const simpleIcon = task.extensionType === 'mcp' ? Server : Sparkles;
const simpleInstallingLabel =
task.extensionType === 'mcp'
? t('addExtension.installStage.mcpInstalling')
: t('addExtension.installStage.skillInstalling');
/** Build detail node for a stage */
const getStageDetail = (
stageKey: InstallStage,
@@ -319,42 +318,60 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
{/* Stage display */}
<div className="space-y-1.5">
{isDone
? /* When done: show all stages with completed style */
STAGES.map((stageConfig) => (
<StageRow
key={stageConfig.key}
icon={stageConfig.icon}
label={t(stageConfig.i18nKey)}
isActive={false}
isCompleted={true}
isError={false}
detail={getStageDetail(stageConfig.key, true)}
/>
))
: isError
? /* Error: show the failed stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={true}
detail={task.error}
/>
)
: /* In progress: only show the current active stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={false}
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
/>
)}
{!isPlugin ? (
/* MCP / Skill: single installing → done/failed row */
<StageRow
icon={simpleIcon}
label={
isDone
? t('addExtension.installStage.installed')
: isError
? t('plugins.installProgress.failed')
: simpleInstallingLabel
}
isActive={!isDone}
isCompleted={isDone}
isError={isError}
detail={isError ? task.error : undefined}
/>
) : isDone ? (
/* When done: show all stages with completed style */
STAGES.map((stageConfig) => (
<StageRow
key={stageConfig.key}
icon={stageConfig.icon}
label={t(stageConfig.i18nKey)}
isActive={false}
isCompleted={true}
isError={false}
detail={getStageDetail(stageConfig.key, true)}
/>
))
) : isError ? (
/* Error: show the failed stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={true}
detail={task.error}
/>
)
) : (
/* In progress: only show the current active stage */
currentStageIndex >= 0 && (
<StageRow
icon={STAGES[currentStageIndex].icon}
label={t(STAGES[currentStageIndex].i18nKey)}
isActive={true}
isCompleted={false}
isError={false}
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
/>
)
)}
</div>
{/* Done banner */}
@@ -28,6 +28,7 @@ export interface PluginInstallTask {
source: 'github' | 'marketplace' | 'local';
stage: InstallStage;
overallProgress: number; // 0-100
extensionType: 'plugin' | 'mcp' | 'skill'; // type of extension being installed
fileSize?: number; // bytes, if known
// Download progress
downloadCurrent?: number; // bytes downloaded so far
@@ -57,6 +58,7 @@ interface PluginInstallTaskContextValue {
taskId: number;
pluginName: string;
source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number;
}) => void;
removeTask: (id: string) => void;
@@ -91,8 +93,8 @@ function mapActionToStage(action: string): InstallStage {
if (lower.includes('dependencies') || lower.includes('requirements'))
return InstallStage.INSTALLING_DEPS;
if (lower.includes('initializ') || lower.includes('setting'))
return InstallStage.INITIALIZING;
if (lower.includes('launch')) return InstallStage.LAUNCHING;
return InstallStage.INSTALLING_DEPS;
if (lower.includes('launch')) return InstallStage.INSTALLING_DEPS;
if (lower.includes('installed') || lower.includes('complete'))
return InstallStage.DONE;
return InstallStage.DOWNLOADING;
@@ -106,7 +108,7 @@ function stageToProgress(stage: InstallStage): number {
case InstallStage.DOWNLOADING:
return 10;
case InstallStage.INSTALLING_DEPS:
return 40;
return 70;
case InstallStage.INITIALIZING:
return 70;
case InstallStage.LAUNCHING:
@@ -135,7 +137,11 @@ function extractSourceFromName(
* Check if a backend task name is a plugin install task.
*/
function isPluginInstallTask(name: string): boolean {
return name.startsWith('plugin-install-');
return (
name.startsWith('plugin-install-') ||
name.startsWith('mcp-install-') ||
name.startsWith('skill-install-')
);
}
/**
@@ -169,13 +175,21 @@ function asyncTaskToPluginInstallTask(task: AsyncTask): PluginInstallTask {
overallProgress = Math.min(95, stageToProgress(stage));
}
const pluginName = str(md.plugin_name) || task.label || `${source} plugin`;
const pluginName = str(md.plugin_name) || task.label || `${source} extension`;
let extensionType: 'plugin' | 'mcp' | 'skill' = 'plugin';
if (task.name.startsWith('mcp-install-')) {
extensionType = 'mcp';
} else if (task.name.startsWith('skill-install-')) {
extensionType = 'skill';
}
return {
id: `${source}-${task.id}`,
taskId: task.id,
pluginName,
source,
extensionType,
stage,
overallProgress,
downloadCurrent: num(md.download_current),
@@ -212,8 +226,9 @@ export function PluginInstallTaskProvider({
// Cleanup all intervals on unmount
useEffect(() => {
const intervals = intervalRefs.current;
return () => {
intervalRefs.current.forEach((interval) => {
intervals.forEach((interval) => {
clearInterval(interval);
});
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
@@ -395,6 +410,7 @@ export function PluginInstallTaskProvider({
converted.startedAt = existing.startedAt;
converted.pluginName = existing.pluginName;
converted.fileSize = existing.fileSize;
converted.extensionType = existing.extensionType;
updatedTasks[idx] = converted;
}
}
@@ -408,20 +424,39 @@ export function PluginInstallTaskProvider({
}
}, [pollTask]);
// Initial sync on mount + periodic sync every 3s
// Initial sync on mount to recover any orphaned tasks
const syncOnMountRef = useRef(syncTasksFromBackend);
syncOnMountRef.current = syncTasksFromBackend;
useEffect(() => {
syncTasksFromBackend();
syncIntervalRef.current = setInterval(syncTasksFromBackend, 3000);
syncOnMountRef.current();
}, []);
// Only poll periodically when there are active (non-terminal) tasks
useEffect(() => {
const hasActiveTasks = tasks.some(
(t) => t.stage !== InstallStage.DONE && t.stage !== InstallStage.ERROR,
);
if (hasActiveTasks) {
syncIntervalRef.current = setInterval(syncTasksFromBackend, 3000);
} else {
if (syncIntervalRef.current) {
clearInterval(syncIntervalRef.current);
syncIntervalRef.current = null;
}
}
return () => {
if (syncIntervalRef.current) clearInterval(syncIntervalRef.current);
};
}, [syncTasksFromBackend]);
}, [tasks, syncTasksFromBackend]);
const addTask = useCallback(
(params: {
taskId: number;
pluginName: string;
source: 'github' | 'marketplace' | 'local';
extensionType: 'plugin' | 'mcp' | 'skill';
fileSize?: number;
}) => {
const taskKey = `${params.source}-${params.taskId}`;
@@ -434,6 +469,7 @@ export function PluginInstallTaskProvider({
taskId: params.taskId,
pluginName: params.pluginName,
source: params.source,
extensionType: params.extensionType,
stage: InstallStage.DOWNLOADING,
overallProgress: 5,
fileSize: params.fileSize,
@@ -4,13 +4,14 @@ import { Progress } from '@/components/ui/progress';
import {
Download,
Package,
Settings,
Rocket,
CheckCircle2,
XCircle,
Loader2,
X,
ListTodo,
Puzzle,
Server,
Sparkles,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -29,12 +30,16 @@ import { cn } from '@/lib/utils';
const STAGE_ICONS: Record<string, React.ElementType> = {
[InstallStage.DOWNLOADING]: Download,
[InstallStage.INSTALLING_DEPS]: Package,
[InstallStage.INITIALIZING]: Settings,
[InstallStage.LAUNCHING]: Rocket,
[InstallStage.DONE]: CheckCircle2,
[InstallStage.ERROR]: XCircle,
};
const EXTENSION_TYPE_ICONS: Record<string, React.ElementType> = {
plugin: Puzzle,
mcp: Server,
skill: Sparkles,
};
function TaskQueueItem({
task,
onClick,
@@ -49,6 +54,40 @@ function TaskQueueItem({
const isError = task.stage === InstallStage.ERROR;
const isRunning = !isDone && !isError;
const StageIcon = STAGE_ICONS[task.stage] || Download;
const TypeIcon = EXTENSION_TYPE_ICONS[task.extensionType] || Puzzle;
const getTypeBadgeClass = () => {
switch (task.extensionType) {
case 'mcp':
return 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300';
case 'skill':
return 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300';
default:
return 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300';
}
};
const getTypeLabel = () => {
switch (task.extensionType) {
case 'mcp':
return 'MCP';
case 'skill':
return t('common.skill');
default:
return t('market.typePlugin');
}
};
const getInstallCompleteMessage = () => {
switch (task.extensionType) {
case 'mcp':
return t('plugins.installProgress.installCompleteMCP');
case 'skill':
return t('plugins.installProgress.installCompleteSkill');
default:
return t('plugins.installProgress.installCompletePlugin');
}
};
const stageLabel = (() => {
switch (task.stage) {
@@ -56,12 +95,10 @@ function TaskQueueItem({
return t('plugins.installProgress.downloading');
case InstallStage.INSTALLING_DEPS:
return t('plugins.installProgress.installingDeps');
case InstallStage.INITIALIZING:
return t('plugins.installProgress.initializing');
case InstallStage.LAUNCHING:
return t('plugins.installProgress.launching');
case InstallStage.DONE:
return t('plugins.installProgress.completed');
return isDone
? getInstallCompleteMessage()
: t('plugins.installProgress.completed');
case InstallStage.ERROR:
return t('plugins.installProgress.failed');
default:
@@ -93,7 +130,19 @@ function TaskQueueItem({
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{task.pluginName}</div>
<div className="flex items-center gap-2">
<div className="text-sm font-medium truncate">{task.pluginName}</div>
<Badge
variant="outline"
className={cn(
'text-[0.6rem] px-1 py-0 flex-shrink-0',
getTypeBadgeClass(),
)}
>
<TypeIcon className="w-3 h-3 mr-0.5" />
{getTypeLabel()}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{stageLabel}</span>
{isRunning && (
@@ -139,7 +188,7 @@ export default function PluginInstallTaskQueue() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="relative px-4 py-5 cursor-pointer">
<Button variant="outline" className="relative px-4 py-4 cursor-pointer">
<ListTodo className="w-4 h-4 mr-2" />
{t('plugins.installProgress.taskQueue')}
{runningCount > 0 && (
@@ -0,0 +1,345 @@
import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import {
BugIcon,
ExternalLink,
Ellipsis,
Trash,
ArrowUp,
Server,
Sparkles,
Puzzle,
} from 'lucide-react';
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
type ExtensionCardComponentProps = {
cardVO: ExtensionCardVO;
onCardClick: () => void;
onDeleteClick: (cardVO: ExtensionCardVO) => void;
onUpgradeClick?: (cardVO: ExtensionCardVO) => void;
};
export default function ExtensionCardComponent({
cardVO,
onCardClick,
onDeleteClick,
onUpgradeClick,
}: ExtensionCardComponentProps) {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [iconFailed, setIconFailed] = useState(false);
const FallbackIcon =
cardVO.type === 'mcp'
? Server
: cardVO.type === 'skill'
? Sparkles
: Puzzle;
const iconSrc =
cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name);
const showFallback = iconFailed || !iconSrc;
const getTypeLabel = (type: ExtensionType) => {
switch (type) {
case 'mcp':
return 'MCP';
case 'skill':
return t('common.skill');
default:
return t('market.typePlugin');
}
};
const getTypeIcon = (type: ExtensionType) => {
switch (type) {
case 'mcp':
return Server;
case 'skill':
return Sparkles;
default:
return Puzzle;
}
};
const renderTypeBadge = (type: ExtensionType) => {
const TypeIcon = getTypeIcon(type);
return (
<Badge
variant="outline"
className="flex-shrink-0 gap-1.5 border-blue-200 bg-blue-50/60 text-[0.7rem] text-blue-700 dark:border-blue-500/40 dark:bg-blue-500/10 dark:text-blue-300"
>
<TypeIcon className="size-3.5" />
{getTypeLabel(type)}
</Badge>
);
};
const renderPluginContent = () => (
<>
<div className="text-[0.7rem] text-muted-foreground truncate w-full">
{cardVO.author} / {cardVO.name}
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-foreground truncate max-w-[10rem]">
{cardVO.label}
</div>
<Badge variant="outline" className="text-[0.7rem] flex-shrink-0">
v{cardVO.version}
</Badge>
{renderTypeBadge(cardVO.type)}
{cardVO.debug && (
<Badge
variant="outline"
className="text-[0.7rem] border-orange-400 text-orange-400 flex-shrink-0"
>
<BugIcon className="w-4 h-4" />
{t('plugins.debugging')}
</Badge>
)}
{!cardVO.debug && (
<>
{cardVO.install_source === 'github' && (
<Badge
variant="outline"
className="text-[0.7rem] border-blue-400 text-blue-400 flex-shrink-0"
>
{t('plugins.fromGithub')}
</Badge>
)}
{cardVO.install_source === 'local' && (
<Badge
variant="outline"
className="text-[0.7rem] border-green-400 text-green-400 flex-shrink-0"
>
{t('plugins.fromLocal')}
</Badge>
)}
{cardVO.install_source === 'marketplace' && (
<Badge
variant="outline"
className="text-[0.7rem] border-purple-400 text-purple-400 flex-shrink-0"
>
{t('plugins.fromMarketplace')}
</Badge>
)}
</>
)}
</div>
<div className="text-[0.8rem] text-muted-foreground line-clamp-2 w-full">
{cardVO.description}
</div>
</>
);
const renderMCPContent = () => (
<>
<div className="text-[0.7rem] text-muted-foreground truncate w-full">
MCP Server
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-foreground truncate max-w-[10rem]">
{cardVO.label}
</div>
{renderTypeBadge('mcp')}
{cardVO.mode && (
<Badge
variant="outline"
className="text-[0.7rem] border-gray-400 text-gray-600 dark:text-gray-300 flex-shrink-0"
>
{cardVO.mode.toUpperCase()}
</Badge>
)}
{(() => {
// Reflect the real runtime status, not just the enabled flag.
// A server can be enabled but still CONNECTING or in ERROR — showing
// "Connected" in those cases is misleading.
const runtime = cardVO.enabled
? (cardVO.runtimeStatus ?? 'connecting')
: 'disabled';
const badgeClass: Record<string, string> = {
connected: 'border-green-400 text-green-600 dark:text-green-400',
connecting: 'border-amber-400 text-amber-600 dark:text-amber-400',
error: 'border-red-400 text-red-600 dark:text-red-400',
disabled: 'border-gray-400 text-gray-600 dark:text-gray-300',
};
const badgeLabel: Record<string, string> = {
connected: t('mcp.statusConnected'),
connecting: t('mcp.connecting'),
error: t('mcp.statusError'),
disabled: t('mcp.statusDisabled'),
};
return (
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${badgeClass[runtime] ?? badgeClass.disabled}`}
>
{badgeLabel[runtime] ?? badgeLabel.disabled}
</Badge>
);
})()}
</div>
<div className="text-[0.8rem] text-muted-foreground line-clamp-2 w-full">
{cardVO.description ||
(cardVO.tools !== undefined && cardVO.tools > 0
? t('mcp.toolCount', { count: cardVO.tools })
: t('mcp.noToolsFound'))}
</div>
</>
);
const renderSkillContent = () => (
<>
<div className="text-[0.7rem] text-muted-foreground truncate w-full">
Skill
</div>
<div className="flex flex-row items-center justify-start gap-[0.4rem] flex-wrap max-w-full">
<div className="text-[1.2rem] text-foreground truncate max-w-[10rem]">
{cardVO.label}
</div>
{renderTypeBadge('skill')}
</div>
<div className="text-[0.8rem] text-muted-foreground line-clamp-2 w-full">
{cardVO.description}
</div>
</>
);
return (
<>
<Card
className="w-full h-[10rem] py-5 px-5 cursor-pointer relative gap-0 shadow-xs transition-shadow duration-200 hover:shadow-md"
onClick={() => onCardClick()}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
{showFallback ? (
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center">
<FallbackIcon className="w-12 h-12 text-blue-500" />
</div>
) : (
<img
src={iconSrc}
alt="extension icon"
className="w-16 h-16 rounded-[8%] flex-shrink-0"
onError={() => setIconFailed(true)}
/>
)}
<div className="flex-1 min-w-0 h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start w-full min-w-0 flex-1 overflow-hidden">
{cardVO.type === 'plugin' && renderPluginContent()}
{cardVO.type === 'mcp' && renderMCPContent()}
{cardVO.type === 'skill' && renderSkillContent()}
</div>
</div>
<div
className="flex flex-col items-center justify-between h-full relative z-20 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-center"></div>
<div className="flex items-center justify-center">
<DropdownMenu
open={dropdownOpen}
onOpenChange={(open) => {
setDropdownOpen(open);
}}
>
<DropdownMenuTrigger asChild>
<div className="relative">
<Button variant="ghost" size="icon">
<Ellipsis className="w-4 h-4" />
</Button>
{cardVO.hasUpdate && (
<div className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-destructive rounded-full border-2 border-card"></div>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{cardVO.type === 'plugin' &&
cardVO.install_source === 'marketplace' && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (onUpgradeClick) {
onUpgradeClick(cardVO);
}
setDropdownOpen(false);
}}
>
<ArrowUp className="w-4 h-4" />
<span>{t('plugins.update')}</span>
{cardVO.hasUpdate && (
<Badge className="ml-auto bg-red-500 hover:bg-red-500 text-white text-[0.6rem] px-1.5 py-0 h-4">
{t('plugins.new')}
</Badge>
)}
</DropdownMenuItem>
)}
{cardVO.type === 'plugin' &&
(cardVO.install_source === 'github' ||
cardVO.install_source === 'marketplace') && (
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
if (cardVO.install_source === 'github') {
window.open(
cardVO.install_info?.github_url as string,
'_blank',
);
} else if (cardVO.install_source === 'marketplace') {
window.open(
getCloudServiceClientSync().getPluginMarketplaceURL(
systemInfo.cloud_service_url,
cardVO.author,
cardVO.name,
),
'_blank',
);
}
setDropdownOpen(false);
}}
>
<ExternalLink className="w-4 h-4" />
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex flex-row items-center justify-start gap-[0.4rem] cursor-pointer text-red-600 focus:text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(cardVO);
setDropdownOpen(false);
}}
>
<Trash className="w-4 h-4" />
<span>
{cardVO.type === 'mcp'
? t('mcp.deleteServer')
: cardVO.type === 'skill'
? t('skills.delete')
: t('plugins.delete')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
</Card>
</>
);
}
@@ -0,0 +1,61 @@
export type ExtensionType = 'plugin' | 'mcp' | 'skill';
export interface IExtensionCardVO {
id: string;
author: string;
label: string;
name: string;
description: string;
version: string;
enabled: boolean;
type: ExtensionType;
iconURL?: string;
install_source?: string;
install_info?: Record<string, unknown>;
status?: string;
debug?: boolean;
hasUpdate?: boolean;
runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
tools?: number;
mode?: 'stdio' | 'sse' | 'http' | 'remote';
}
export class ExtensionCardVO implements IExtensionCardVO {
id: string;
author: string;
label: string;
name: string;
description: string;
version: string;
enabled: boolean;
type: ExtensionType;
iconURL?: string;
install_source?: string;
install_info?: Record<string, unknown>;
status?: string;
debug?: boolean;
hasUpdate?: boolean;
runtimeStatus?: 'connecting' | 'connected' | 'error' | 'disabled';
tools?: number;
mode?: 'stdio' | 'sse' | 'http' | 'remote';
constructor(prop: IExtensionCardVO) {
this.id = prop.id;
this.author = prop.author;
this.label = prop.label;
this.name = prop.name;
this.description = prop.description;
this.version = prop.version;
this.enabled = prop.enabled;
this.type = prop.type;
this.iconURL = prop.iconURL;
this.install_source = prop.install_source;
this.install_info = prop.install_info;
this.status = prop.status;
this.debug = prop.debug;
this.hasUpdate = prop.hasUpdate;
this.runtimeStatus = prop.runtimeStatus;
this.tools = prop.tools;
this.mode = prop.mode;
}
}
@@ -9,11 +9,12 @@ export interface IPluginCardVO {
enabled: boolean;
priority: number;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
install_info: Record<string, any>;
status: string;
components: PluginComponent[];
debug: boolean;
hasUpdate?: boolean;
type?: 'plugin' | 'mcp' | 'skill';
}
export class PluginCardVO implements IPluginCardVO {
@@ -26,10 +27,11 @@ export class PluginCardVO implements IPluginCardVO {
priority: number;
debug: boolean;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
install_info: Record<string, any>;
status: string;
components: PluginComponent[];
hasUpdate?: boolean;
type?: 'plugin' | 'mcp' | 'skill';
constructor(prop: IPluginCardVO) {
this.author = prop.author;
@@ -45,5 +47,6 @@ export class PluginCardVO implements IPluginCardVO {
this.install_source = prop.install_source;
this.install_info = prop.install_info;
this.hasUpdate = prop.hasUpdate;
this.type = prop.type;
}
}
@@ -1,7 +1,7 @@
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
import { useNavigate } from 'react-router-dom';
import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/PluginCardVO';
import PluginCardComponent from '@/app/home/plugins/components/plugin-installed/plugin-card/PluginCardComponent';
import { ExtensionCardVO, ExtensionType } from './ExtensionCardVO';
import ExtensionCardComponent from './ExtensionCardComponent';
import styles from '@/app/home/plugins/plugins.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClientSync } from '@/app/infra/http';
@@ -21,223 +21,347 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react';
export interface PluginInstalledComponentRef {
refreshPluginList: () => void;
}
enum PluginOperationType {
enum ExtensionOperationType {
DELETE = 'DELETE',
UPDATE = 'UPDATE',
}
const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
(props, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshPlugins } = useSidebarData();
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>(
PluginOperationType.DELETE,
);
const [targetPlugin, setTargetPlugin] = useState<PluginCardVO | null>(null);
const [deleteData, setDeleteData] = useState<boolean>(false);
export type FilterType = 'all' | ExtensionType;
const asyncTask = useAsyncTask({
onSuccess: () => {
const successMessage =
operationType === PluginOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
setShowOperationModal(false);
getPluginList();
refreshPlugins();
},
onError: () => {
// Error is already handled in the hook state
},
});
export const FilterOptions = [
{
value: 'all' as FilterType,
labelKey: 'market.filters.allFormats',
icon: null,
},
{
value: 'plugin' as FilterType,
labelKey: 'market.typePlugin',
icon: Puzzle,
},
{
value: 'mcp' as FilterType,
labelKey: 'market.typeMCP',
icon: Server,
},
{
value: 'skill' as FilterType,
labelKey: 'market.typeSkill',
icon: Sparkles,
},
];
useEffect(() => {
initData();
}, []);
interface PluginInstalledComponentProps {
filterType: FilterType;
groupByType: boolean;
}
function initData() {
getPluginList();
}
const PluginInstalledComponent = forwardRef<
PluginInstalledComponentRef,
PluginInstalledComponentProps
>(({ filterType, groupByType }, ref) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
const [extensionList, setExtensionList] = useState<ExtensionCardVO[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<ExtensionOperationType>(
ExtensionOperationType.DELETE,
);
const [targetExtension, setTargetExtension] =
useState<ExtensionCardVO | null>(null);
const [deleteData, setDeleteData] = useState<boolean>(false);
async function getPluginList() {
try {
// 获取已安装插件列表
const installedPluginsResp = await httpClient.getPlugins();
const installedPlugins = installedPluginsResp.plugins;
const asyncTask = useAsyncTask({
onSuccess: () => {
const successMessage =
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteSuccess')
: t('plugins.updateSuccess');
toast.success(successMessage);
setShowOperationModal(false);
getExtensionList();
refreshPlugins();
refreshMCPServers();
refreshSkills();
},
onError: () => {},
});
// 获取市场插件列表
const client = getCloudServiceClientSync();
const marketplaceResp = await client.getMarketplacePlugins(1, 100);
const marketplacePlugins = marketplaceResp.plugins;
useEffect(() => {
initData();
}, []);
// 创建市场插件映射,便于快速查找
const marketplacePluginMap = new Map();
marketplacePlugins.forEach((plugin) => {
const key = `${plugin.author}/${plugin.name}`;
marketplacePluginMap.set(key, plugin);
});
function initData() {
getExtensionList();
}
// 转换并比较版本号
const pluginCards = installedPlugins.map((plugin) => {
const cardVO = new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
description: extractI18nObject(
plugin.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
),
debug: plugin.debug,
enabled: plugin.enabled,
name: plugin.manifest.manifest.metadata.name,
version: plugin.manifest.manifest.metadata.version ?? '',
status: plugin.status,
components: plugin.components,
priority: plugin.priority,
install_source: plugin.install_source,
install_info: plugin.install_info,
});
async function getExtensionList(silent = false) {
if (!silent) setLoading(true);
try {
const client = getCloudServiceClientSync();
// 检查是否来自市场且有更新
if (cardVO.install_source === 'marketplace') {
const marketplaceKey = `${cardVO.author}/${cardVO.name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
if (marketplacePlugin && marketplacePlugin.latest_version) {
cardVO.hasUpdate = isNewerVersion(
const [extensionsResp, marketplaceResp] = await Promise.all([
httpClient.getExtensions().catch(() => ({ extensions: [] })),
client.getMarketplacePlugins(1, 100).catch(() => ({ plugins: [] })),
]);
const marketplacePluginMap = new Map<string, any>();
marketplaceResp.plugins.forEach((plugin: any) => {
const key = `${plugin.author}/${plugin.name}`;
marketplacePluginMap.set(key, plugin);
});
const extensions: ExtensionCardVO[] = [];
for (const item of extensionsResp.extensions) {
if (item.type === 'plugin') {
const plugin = item.plugin;
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const marketplaceKey = `${author}/${name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
let hasUpdate = false;
if (plugin.install_source === 'marketplace' && marketplacePlugin) {
if (marketplacePlugin.latest_version) {
hasUpdate = isNewerVersion(
marketplacePlugin.latest_version,
cardVO.version,
meta.version ?? '',
);
}
}
return cardVO;
});
setPluginList(pluginCards);
} catch (error) {
console.error('获取插件列表失败:', error);
// 失败时仍显示已安装插件,不影响用户体验
const installedPluginsResp = await httpClient.getPlugins();
setPluginList(
installedPluginsResp.plugins.map((plugin) => {
return new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
extensions.push(
new ExtensionCardVO({
id: marketplaceKey,
author,
label: extractI18nObject(meta.label) || name,
name,
description: extractI18nObject(
plugin.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
meta.description ?? { en_US: '', zh_Hans: '' },
),
debug: plugin.debug,
version: meta.version ?? '',
enabled: plugin.enabled,
name: plugin.manifest.manifest.metadata.name,
version: plugin.manifest.manifest.metadata.version ?? '',
status: plugin.status,
components: plugin.components,
priority: plugin.priority,
type: marketplacePlugin?.type || 'plugin',
iconURL: httpClient.getPluginIconURL(author, name),
install_source: plugin.install_source,
install_info: plugin.install_info,
});
}),
);
status: plugin.status,
debug: plugin.debug,
hasUpdate,
}),
);
} else if (item.type === 'mcp') {
const server = item.server;
extensions.push(
new ExtensionCardVO({
id: server.name,
author: '',
label: server.name.replace(/__/g, '/'),
name: server.name,
description: '',
version: '',
enabled: server.enable,
type: 'mcp',
iconURL: httpClient.getPluginIconURL('mcp', server.name),
status: server.runtime_info?.status,
runtimeStatus: server.runtime_info?.status,
tools: server.runtime_info?.tool_count || 0,
mode: server.mode,
}),
);
} else if (item.type === 'skill') {
const skill = item.skill;
extensions.push(
new ExtensionCardVO({
id: skill.name,
author: '',
label: skill.display_name || skill.name,
name: skill.name,
description: skill.description || '',
version: '',
enabled: true,
type: 'skill',
iconURL: httpClient.getPluginIconURL('skill', skill.name),
}),
);
}
}
setExtensionList(extensions);
} catch (error) {
console.error('Failed to fetch extension list:', error);
if (!silent) setExtensionList([]);
} finally {
if (!silent) setLoading(false);
}
}
useImperativeHandle(ref, () => ({
refreshPluginList: getPluginList,
}));
// While any MCP server is still connecting, poll quietly so the status badge
// transitions (connecting -> connected/error) without a manual refresh.
useEffect(() => {
const hasConnecting = extensionList.some(
(e) => e.type === 'mcp' && e.enabled && e.runtimeStatus === 'connecting',
);
if (!hasConnecting) return;
const timer = setInterval(() => {
getExtensionList(true);
}, 3000);
return () => clearInterval(timer);
}, [extensionList]);
function handlePluginClick(plugin: PluginCardVO) {
const pluginId = `${plugin.author}/${plugin.name}`;
navigate(`/home/plugins?id=${encodeURIComponent(pluginId)}`);
useImperativeHandle(ref, () => ({
refreshPluginList: getExtensionList,
}));
function handleExtensionClick(extension: ExtensionCardVO) {
if (extension.type === 'mcp') {
navigate(`/home/mcp?id=${encodeURIComponent(extension.id)}`);
} else if (extension.type === 'skill') {
navigate(`/home/skills?id=${encodeURIComponent(extension.id)}`);
} else {
const extensionId = `${extension.author}/${extension.name}`;
navigate(`/home/extensions?id=${encodeURIComponent(extensionId)}`);
}
}
function handlePluginDelete(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.DELETE);
setShowOperationModal(true);
setDeleteData(false);
asyncTask.reset();
}
function handleExtensionDelete(extension: ExtensionCardVO) {
setTargetExtension(extension);
setOperationType(ExtensionOperationType.DELETE);
setShowOperationModal(true);
setDeleteData(false);
asyncTask.reset();
}
function handlePluginUpdate(plugin: PluginCardVO) {
setTargetPlugin(plugin);
setOperationType(PluginOperationType.UPDATE);
setShowOperationModal(true);
asyncTask.reset();
}
function handleExtensionUpdate(extension: ExtensionCardVO) {
setTargetExtension(extension);
setOperationType(ExtensionOperationType.UPDATE);
setShowOperationModal(true);
asyncTask.reset();
}
function executeOperation() {
if (!targetPlugin) return;
function executeOperation() {
if (!targetExtension) return;
const apiCall =
operationType === PluginOperationType.DELETE
? httpClient.removePlugin(
targetPlugin.author,
targetPlugin.name,
deleteData,
)
: httpClient.upgradePlugin(targetPlugin.author, targetPlugin.name);
apiCall
.then((res) => {
asyncTask.startTask(res.task_id);
if (targetExtension.type === 'mcp') {
httpClient
.deleteMCPServer(targetExtension.name)
.then(() => {
toast.success(t('mcp.deleteSuccess'));
setShowOperationModal(false);
getExtensionList();
refreshMCPServers();
})
.catch((error) => {
const errorMessage =
operationType === PluginOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
toast.error(t('mcp.deleteError') + error.message);
});
return;
}
return (
<>
<Dialog
open={showOperationModal}
onOpenChange={(open) => {
if (!open) {
setShowOperationModal(false);
setTargetPlugin(null);
asyncTask.reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{operationType === PluginOperationType.DELETE
? t('plugins.deleteConfirm')
: t('plugins.updateConfirm')}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div className="flex flex-col gap-4">
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDeletePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})
: t('plugins.confirmUpdatePlugin', {
author: targetPlugin?.author ?? '',
name: targetPlugin?.name ?? '',
})}
</div>
{operationType === PluginOperationType.DELETE && (
if (targetExtension.type === 'skill') {
httpClient
.deleteSkill(targetExtension.name)
.then(() => {
toast.success(t('skills.deleteSuccess'));
setShowOperationModal(false);
getExtensionList();
refreshSkills();
})
.catch((error) => {
toast.error(t('skills.deleteError') + error.message);
});
return;
}
const apiCall =
operationType === ExtensionOperationType.DELETE
? httpClient.removePlugin(
targetExtension.author,
targetExtension.name,
deleteData,
)
: httpClient.upgradePlugin(
targetExtension.author,
targetExtension.name,
);
apiCall
.then((res) => {
asyncTask.startTask(res.task_id);
})
.catch((error) => {
const errorMessage =
operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError') + error.message
: t('plugins.updateError') + error.message;
toast.error(errorMessage);
});
}
const filteredExtensions = extensionList.filter((ext) => {
if (filterType === 'all') return true;
return ext.type === filterType;
});
const showGrouped = groupByType && filterType === 'all';
const groupOrder: ExtensionType[] = ['plugin', 'mcp', 'skill'];
const groupedExtensions = groupOrder
.map((type) => ({
type,
labelKey: FilterOptions.find((o) => o.value === type)!.labelKey,
items: filteredExtensions.filter((ext) => ext.type === type),
}))
.filter((g) => g.items.length > 0);
const getDeleteConfirmMessage = () => {
if (!targetExtension) return '';
if (targetExtension.type === 'mcp') {
return t('mcp.confirmDeleteServer');
}
if (targetExtension.type === 'skill') {
return t('skills.deleteConfirmation');
}
return t('plugins.confirmDeletePlugin', {
author: targetExtension.author,
name: targetExtension.name,
});
};
return (
<>
<Dialog
open={showOperationModal}
onOpenChange={(open) => {
if (!open) {
setShowOperationModal(false);
setTargetExtension(null);
asyncTask.reset();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleteConfirm')
: t('plugins.updateConfirm')}
</DialogTitle>
</DialogHeader>
<DialogDescription>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<div className="flex flex-col gap-4">
<div>{getDeleteConfirmMessage()}</div>
{operationType === ExtensionOperationType.DELETE &&
targetExtension?.type === 'plugin' && (
<div className="flex items-center space-x-2">
<Checkbox
id="delete-data"
@@ -254,113 +378,147 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
</label>
</div>
)}
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{operationType === PluginOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => {
setShowOperationModal(false);
setTargetPlugin(null);
asyncTask.reset();
}}
>
{t('plugins.cancel')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant={
operationType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
onClick={() => {
executeOperation();
}}
>
{operationType === PluginOperationType.DELETE
? t('plugins.confirmDelete')
: t('plugins.confirmUpdate')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button
variant={
operationType === PluginOperationType.DELETE
? 'destructive'
: 'default'
}
disabled
>
{operationType === PluginOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="default"
onClick={() => {
setShowOperationModal(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<div>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</div>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<div>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleteError')
: t('plugins.updateError')}
<div className="text-red-500">{asyncTask.error}</div>
</div>
)}
</DialogDescription>
<DialogFooter>
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant="outline"
onClick={() => {
setShowOperationModal(false);
setTargetExtension(null);
asyncTask.reset();
}}
>
{t('plugins.cancel')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
<Button
variant={
operationType === ExtensionOperationType.DELETE
? 'destructive'
: 'default'
}
onClick={() => {
executeOperation();
}}
>
{operationType === ExtensionOperationType.DELETE
? t('plugins.confirmDelete')
: t('plugins.confirmUpdate')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.RUNNING && (
<Button
variant={
operationType === ExtensionOperationType.DELETE
? 'destructive'
: 'default'
}
disabled
>
{operationType === ExtensionOperationType.DELETE
? t('plugins.deleting')
: t('plugins.updating')}
</Button>
)}
{asyncTask.status === AsyncTaskStatus.ERROR && (
<Button
variant="default"
onClick={() => {
setShowOperationModal(false);
asyncTask.reset();
}}
>
{t('plugins.close')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{pluginList.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H20C20.5523 5 21 5.44772 21 6V10.1707C21 10.4953 20.8424 10.7997 20.5774 10.9872C20.3123 11.1746 19.9728 11.2217 19.6668 11.1135C19.4595 11.0403 19.2355 11 19 11C17.8954 11 17 11.8954 17 13C17 14.1046 17.8954 15 19 15C19.2355 15 19.4595 14.9597 19.6668 14.8865C19.9728 14.7783 20.3123 14.8254 20.5774 15.0128C20.8424 15.2003 21 15.5047 21 15.8293V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H19V17C16.7909 17 15 15.2091 15 13C15 10.7909 16.7909 9 19 9V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
</svg>
<div className="text-lg mb-2">{t('plugins.noPluginInstalled')}</div>
{loading ? (
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
<Loader2 className="h-[3rem] w-[3rem] animate-spin" />
<div className="text-lg mb-2">{t('plugins.loadingExtensions')}</div>
</div>
) : filteredExtensions.length === 0 ? (
<div className="flex flex-col items-center justify-center text-muted-foreground min-h-[60vh] w-full gap-2">
<Puzzle className="h-[3rem] w-[3rem]" />
<div className="text-lg mb-2">
{t('plugins.noExtensionInstalled')}
</div>
) : (
<div className={`${styles.pluginListContainer}`}>
{pluginList.map((vo, index) => {
return (
<div key={index}>
<PluginCardComponent
cardVO={vo}
onCardClick={() => handlePluginClick(vo)}
onDeleteClick={() => handlePluginDelete(vo)}
onUpgradeClick={() => handlePluginUpdate(vo)}
/>
</div>
);
})}
</div>
)}
</>
);
},
);
</div>
) : showGrouped ? (
<div className="flex flex-col gap-4 pb-4">
{groupedExtensions.map((group) => (
<div key={group.type} className="flex flex-col">
<div className="px-[0.8rem] flex items-center gap-2 mb-2">
<h3 className="text-sm font-semibold text-foreground">
{t(group.labelKey)}
</h3>
<span className="text-xs text-muted-foreground">
({group.items.length})
</span>
</div>
<div className="px-[0.8rem] grid gap-5 sm:gap-8 [grid-template-columns:repeat(auto-fill,minmax(min(100%,22rem),1fr))] sm:[grid-template-columns:repeat(auto-fill,minmax(min(100%,28rem),1fr))] items-start">
{group.items.map((vo, index) => (
<div key={vo.id || index}>
<ExtensionCardComponent
cardVO={vo}
onCardClick={() => handleExtensionClick(vo)}
onDeleteClick={() => handleExtensionDelete(vo)}
onUpgradeClick={
vo.type === 'plugin'
? () => handleExtensionUpdate(vo)
: undefined
}
/>
</div>
))}
</div>
</div>
))}
</div>
) : (
<div className={`${styles.pluginListContainer}`}>
{filteredExtensions.map((vo, index) => {
return (
<div key={vo.id || index}>
<ExtensionCardComponent
cardVO={vo}
onCardClick={() => handleExtensionClick(vo)}
onDeleteClick={() => handleExtensionDelete(vo)}
onUpgradeClick={
vo.type === 'plugin'
? () => handleExtensionUpdate(vo)
: undefined
}
/>
</div>
);
})}
</div>
)}
</>
);
});
export default PluginInstalledComponent;
@@ -60,6 +60,24 @@ export default function PluginCardComponent({
>
v{cardVO.version}
</Badge>
{cardVO.type && (
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${
cardVO.type === 'mcp'
? 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300'
: cardVO.type === 'skill'
? 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300'
: 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300'
}`}
>
{cardVO.type === 'mcp'
? 'MCP'
: cardVO.type === 'skill'
? t('common.skill')
: t('market.typePlugin')}
</Badge>
)}
{cardVO.debug && (
<Badge
variant="outline"
@@ -4,10 +4,16 @@ import { Plugin } from '@/app/infra/entities/plugin';
import { httpClient } from '@/app/infra/http/HttpClient';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
export default function PluginForm({
pluginAuthor,
@@ -36,7 +42,6 @@ export default function PluginForm({
setPluginConfig(res);
// 提取初始配置中的所有文件 key
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const extractFileKeys = (obj: any): string[] => {
const keys: string[] = [];
if (obj && typeof obj === 'object') {
@@ -71,7 +76,6 @@ export default function PluginForm({
);
// 提取最终保存的配置中的所有文件 key
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const extractFileKeys = (obj: any): string[] => {
const keys: string[] = [];
if (obj && typeof obj === 'object') {
@@ -137,65 +141,34 @@ export default function PluginForm({
}
return (
<div>
<div className="space-y-2">
<div className="text-lg font-medium">
{extractI18nObject(pluginInfo.manifest.manifest.metadata.label)}
</div>
<div className="text-sm text-gray-500 pb-2">
{extractI18nObject(
pluginInfo.manifest.manifest.metadata.description ?? {
en_US: '',
zh_Hans: '',
},
<div className="min-w-0 max-w-full space-y-4">
<Card className="min-w-0 overflow-x-hidden">
<CardHeader>
<CardTitle>{t('plugins.pluginConfig')}</CardTitle>
<CardDescription>{t('plugins.saveConfig')}</CardDescription>
</CardHeader>
<CardContent className="min-w-0 overflow-x-hidden">
{pluginInfo.manifest.manifest.spec.config.length > 0 ? (
<DynamicFormComponent
itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => {
// 只保存表单值的引用,不触发状态更新
currentFormValues.current = values;
}}
onFileUploaded={(fileKey) => {
// 追踪上传的文件
uploadedFileKeys.current.add(fileKey);
}}
/>
) : (
<div className="text-sm text-muted-foreground">
{t('plugins.pluginNoConfig')}
</div>
)}
</div>
<div className="mb-4 flex flex-row items-center justify-start gap-[0.4rem]">
<PluginComponentList
components={(() => {
const componentKindCount: Record<string, number> = {};
for (const component of pluginInfo.components) {
const kind = component.manifest.manifest.kind;
if (componentKindCount[kind]) {
componentKindCount[kind]++;
} else {
componentKindCount[kind] = 1;
}
}
return componentKindCount;
})()}
showComponentName={true}
showTitle={false}
useBadge={true}
t={t}
/>
</div>
</CardContent>
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
<DynamicFormComponent
itemConfigList={pluginInfo.manifest.manifest.spec.config}
initialValues={pluginConfig.config as Record<string, object>}
onSubmit={(values) => {
// 只保存表单值的引用,不触发状态更新
currentFormValues.current = values;
}}
onFileUploaded={(fileKey) => {
// 追踪上传的文件
uploadedFileKeys.current.add(fileKey);
}}
/>
)}
{pluginInfo.manifest.manifest.spec.config.length === 0 && (
<div className="text-sm text-gray-500">
{t('plugins.pluginNoConfig')}
</div>
)}
</div>
{pluginInfo.manifest.manifest.spec.config.length > 0 && (
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
<div className="flex justify-end gap-2">
<CardFooter className="justify-end">
<Button
type="submit"
onClick={() => handleSubmit()}
@@ -203,9 +176,9 @@ export default function PluginForm({
>
{isSaving ? t('plugins.saving') : t('plugins.saveConfig')}
</Button>
</div>
</div>
)}
</CardFooter>
)}
</Card>
</div>
);
}
@@ -0,0 +1,160 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
import { PluginLogEntry } from '@/app/infra/entities/plugin';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RefreshCw } from 'lucide-react';
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
function levelClassName(level: string): string {
switch (level) {
case 'ERROR':
case 'CRITICAL':
return 'text-red-500';
case 'WARNING':
return 'text-amber-500';
case 'DEBUG':
return 'text-gray-400 dark:text-gray-500';
default:
return 'text-gray-700 dark:text-gray-300';
}
}
export default function PluginLogs({
pluginAuthor,
pluginName,
}: {
pluginAuthor: string;
pluginName: string;
}) {
const { t } = useTranslation();
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [level, setLevel] = useState<string>('ALL');
const [autoRefresh, setAutoRefresh] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomRef = useRef(true);
const fetchLogs = useCallback(() => {
setIsLoading(true);
httpClient
.getPluginLogs(
pluginAuthor,
pluginName,
500,
level === 'ALL' ? undefined : level,
)
.then((res) => {
setLogs(res.logs ?? []);
})
.catch(() => {
setLogs([]);
})
.finally(() => {
setIsLoading(false);
});
}, [pluginAuthor, pluginName, level]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// Auto-refresh poll loop.
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchLogs, 3000);
return () => clearInterval(timer);
}, [autoRefresh, fetchLogs]);
// Keep view pinned to bottom when the user is already at the bottom.
useEffect(() => {
const el = scrollRef.current;
if (el && atBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [logs]);
function handleScroll() {
const el = scrollRef.current;
if (!el) return;
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1 pb-3 sm:px-6">
<Select value={level} onValueChange={setLevel}>
<SelectTrigger className="h-8 w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVEL_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt === 'ALL' ? t('plugins.logsLevelAll') : opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={fetchLogs}
disabled={isLoading}
>
<RefreshCw
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
/>
{t('plugins.logsRefresh')}
</Button>
<div className="flex items-center gap-2">
<Switch
id="plugin-logs-auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label
htmlFor="plugin-logs-auto-refresh"
className="cursor-pointer text-sm font-normal text-muted-foreground"
>
{t('plugins.logsAutoRefresh')}
</Label>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-3 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40 sm:px-6"
>
{logs.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{t('plugins.logsEmpty')}
</div>
) : (
logs.map((entry, idx) => (
<div
key={`${entry.ts}-${idx}`}
className={`whitespace-pre-wrap break-all ${levelClassName(
entry.level,
)}`}
>
{entry.text}
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,75 @@
import { Fragment } from 'react';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function PluginComponentList({
components,
showComponentName,
showTitle,
useBadge,
t,
responsive = false,
}: {
components: Record<string, number>;
showComponentName: boolean;
showTitle: boolean;
useBadge: boolean;
t: TFunction;
responsive?: boolean;
}) {
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
KnowledgeEngine: <Book className="w-5 h-5" />,
Parser: <FileText className="w-5 h-5" />,
};
const componentKindList = Object.keys(components || {});
return (
<>
{showTitle && <div>{t('market.componentsList')}</div>}
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
return (
<Fragment key={kind}>
{useBadge && (
<Badge variant="outline" className="flex items-center gap-1">
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
</span>
) : (
showComponentName && t('market.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</Badge>
)}
{!useBadge && (
<div className="flex flex-row items-center justify-start gap-[0.2rem]">
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
</span>
) : (
showComponentName && t('market.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</div>
)}
</Fragment>
);
})}
</>
)}
{componentKindList.length === 0 && <div>{t('market.noComponents')}</div>}
</>
);
}
@@ -8,30 +8,50 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Search,
Puzzle,
Server,
Sparkles,
Wrench,
AudioWaveform,
Hash,
Book,
FileText,
PanelTop,
AppWindow,
SlidersHorizontal,
X,
Info,
} from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import { getCloudServiceClientSync } from '@/app/infra/http';
import { RecommendationLists } from './RecommendationLists';
import type { RecommendationList } from './RecommendationLists';
import {
getCloudServiceClient,
getCloudServiceClientSync,
} from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin';
import { PluginV4, PluginV4Status } from '@/app/infra/entities/plugin';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { TagsFilter } from './TagsFilter';
import { Button } from '@/components/ui/button';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
import { RecommendationLists, RecommendationList } from './RecommendationLists';
interface SortOption {
value: string;
label: string;
@@ -39,47 +59,95 @@ interface SortOption {
sortOrder: string;
}
// Persist the market filter conditions (type / component / tags / sort) across
// visits via localStorage.
const MARKET_FILTERS_KEY = 'langbot_market_filters';
interface MarketFilters {
typeFilter?: string;
componentFilter?: string;
selectedTags?: string[];
sortOption?: string;
}
function loadMarketFilters(): MarketFilters {
try {
return JSON.parse(localStorage.getItem(MARKET_FILTERS_KEY) || '{}');
} catch {
return {};
}
}
// 内部组件,用于处理搜索参数
function MarketPageContent({
installPlugin,
headerActions,
}: {
installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode;
}) {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const validCategories = [
'Tool',
'Command',
'EventListener',
'KnowledgeEngine',
'Parser',
'Page',
const validTypes = ['plugin', 'mcp', 'skill'];
const extensionTypeOptions = [
{ value: 'all', label: t('market.filters.allFormats'), icon: null },
{ value: 'plugin', label: t('market.typePlugin'), icon: Puzzle },
{ value: 'mcp', label: t('market.typeMCP'), icon: Server },
{ value: 'skill', label: t('market.typeSkill'), icon: Sparkles },
];
const [searchQuery, setSearchQuery] = useState('');
const [componentFilter, setComponentFilter] = useState<string>(() => {
const category = searchParams.get('category');
if (category && validCategories.includes(category)) {
return category;
const [componentFilter, setComponentFilter] = useState<string>(
() => loadMarketFilters().componentFilter ?? 'all',
);
const [typeFilter, setTypeFilter] = useState<string>(() => {
const type = searchParams.get('type');
if (type && validTypes.includes(type)) {
return type;
}
return 'all';
const saved = loadMarketFilters().typeFilter;
return saved && validTypes.includes(saved) ? saved : 'all';
});
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const activeAdvancedFilters =
(typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1);
const [selectedTags, setSelectedTags] = useState<string[]>(
() => loadMarketFilters().selectedTags ?? [],
);
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
const [tagNames, setTagNames] = useState<Record<string, string>>({});
const [recommendationLists, setRecommendationLists] = useState<
RecommendationList[]
>([]);
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const [sortOption, setSortOption] = useState('install_count_desc');
const [recommendationLists, setRecommendationLists] = useState<
RecommendationList[]
>([]);
// Per-format extension counts shown next to the type filter options.
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
const [sortOption, setSortOption] = useState<string>(
() => loadMarketFilters().sortOption ?? 'install_count_desc',
);
const pageSize = 12; // 每页12个
// Persist filter conditions so they survive navigation / reload.
useEffect(() => {
try {
localStorage.setItem(
MARKET_FILTERS_KEY,
JSON.stringify({
typeFilter,
componentFilter,
selectedTags,
sortOption,
}),
);
} catch {
// ignore storage errors
}
}, [typeFilter, componentFilter, selectedTags, sortOption]);
const pageSize = 24; // 每页24个
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const isComposingRef = useRef(false);
@@ -112,6 +180,32 @@ function MarketPageContent({
},
];
const componentOptions = [
{ value: 'all', label: t('market.allComponents'), icon: null },
{ value: 'Tool', label: t('market.componentName.Tool'), icon: Wrench },
{ value: 'Command', label: t('market.componentName.Command'), icon: Hash },
{
value: 'EventListener',
label: t('market.componentName.EventListener'),
icon: AudioWaveform,
},
{
value: 'KnowledgeEngine',
label: t('market.componentName.KnowledgeEngine'),
icon: Book,
},
{
value: 'Parser',
label: t('market.componentName.Parser'),
icon: FileText,
},
{
value: 'Page',
label: t('market.componentName.Page'),
icon: AppWindow,
},
];
// 获取当前排序参数
const getCurrentSort = useCallback(() => {
const option = sortOptions.find((opt) => opt.value === sortOption);
@@ -121,29 +215,43 @@ function MarketPageContent({
}, [sortOption]);
// 将API响应转换为VO对象
const transformToVO = useCallback((plugin: PluginV4): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: plugin.author + ' / ' + plugin.name,
author: plugin.author,
pluginName: plugin.name,
label: extractI18nObject(plugin.label),
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count,
iconURL: getCloudServiceClientSync().getPluginIconURL(
const transformToVO = useCallback(
(plugin: PluginV4): PluginMarketCardVO => {
const cloudClient = getCloudServiceClientSync();
const iconURL = cloudClient.resolveMarketplaceIconURL(
plugin.type,
plugin.author,
plugin.name,
),
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
});
}, []);
plugin.icon,
);
return new PluginMarketCardVO({
pluginId: plugin.author + ' / ' + plugin.name,
author: plugin.author,
pluginName: plugin.name,
label: extractI18nObject(plugin.label),
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count || 0,
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components || {},
tags: plugin.tags || [],
type: plugin.type,
});
},
[t],
);
// 获取插件列表
const fetchPlugins = useCallback(
async (page: number, isSearch: boolean = false, reset: boolean = false) => {
async (
page: number,
isSearch: boolean = false,
reset: boolean = false,
queryOverride?: string,
) => {
if (page === 1) {
setIsLoading(true);
} else {
@@ -152,31 +260,23 @@ function MarketPageContent({
try {
const { sortBy, sortOrder } = getCurrentSort();
const filterValue =
componentFilter === 'all' ? undefined : componentFilter;
const query = (queryOverride ?? searchQuery).trim();
// Always use searchMarketplacePlugins to support component filtering and tags filtering
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
await getCloudServiceClientSync().searchMarketplaceExtensions({
query: isSearch ? query : '',
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
);
page_size: pageSize,
sort_by: sortBy,
sort_order: sortOrder,
type_filter: typeFilter === 'all' ? undefined : typeFilter,
component_filter:
componentFilter === 'all' ? undefined : componentFilter,
tags_filter: selectedTags.length > 0 ? selectedTags : undefined,
});
const data: ApiRespMarketplacePlugins = response;
const newPlugins = data.plugins
.filter((plugin) => {
// Hide plugins that only contain deprecated KnowledgeRetriever components
const keys = Object.keys(plugin.components || {});
return !(
keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')
);
})
.map(transformToVO);
const newPlugins = data.plugins.map(transformToVO);
const total = data.total;
if (reset || page === 1) {
@@ -187,8 +287,10 @@ function MarketPageContent({
setTotal(total);
setHasMore(
data.plugins.length === pageSize &&
plugins.length + newPlugins.length < total,
newPlugins.length > 0 &&
(reset || page === 1
? newPlugins.length
: plugins.length + newPlugins.length) < total,
);
} catch (error) {
console.error('Failed to fetch plugins:', error);
@@ -206,16 +308,36 @@ function MarketPageContent({
transformToVO,
plugins.length,
getCurrentSort,
typeFilter,
],
);
// 初始加载
useEffect(() => {
fetchPlugins(1, false, true);
fetchAvailableTags();
// Resolve the cloud service base URL (from system info) before any
// marketplace fetch — otherwise the sync client may still hold the default
// URL and hit space.langbot.app instead of the configured instance.
(async () => {
await getCloudServiceClient();
fetchPlugins(1, false, true);
fetchAvailableTags();
fetchRecommendationLists();
fetchTypeCounts();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 获取推荐列表(精选,混合插件/MCP/Skill)
const fetchRecommendationLists = async () => {
try {
const client = await getCloudServiceClient();
const { lists } = await client.getRecommendationLists();
setRecommendationLists(lists || []);
} catch (error) {
console.error('Failed to fetch recommendation lists:', error);
}
};
// 获取可用标签
const fetchAvailableTags = async () => {
try {
@@ -240,19 +362,31 @@ function MarketPageContent({
}
};
// Fetch recommendation lists
useEffect(() => {
async function fetchRecommendationLists() {
try {
const response =
await getCloudServiceClientSync().getRecommendationLists();
setRecommendationLists(response.lists || []);
} catch (error) {
console.error('Failed to fetch recommendation lists:', error);
}
// 获取各扩展格式的数量(用于筛选器标签上的计数)
const fetchTypeCounts = async () => {
const types = ['plugin', 'mcp', 'skill'];
try {
const results = await Promise.all(
types.map((type) =>
getCloudServiceClientSync()
.searchMarketplaceExtensions({
page: 1,
page_size: 1,
type_filter: type,
})
.then((res) => res.total)
.catch(() => 0),
),
);
const counts: Record<string, number> = {};
types.forEach((type, i) => {
counts[type] = results[i];
});
setTypeCounts(counts);
} catch (error) {
console.error('Failed to fetch extension type counts:', error);
}
fetchRecommendationLists();
}, []);
};
// 搜索功能
const handleSearch = useCallback(
@@ -260,7 +394,7 @@ function MarketPageContent({
setSearchQuery(query);
setCurrentPage(1);
setPlugins([]);
fetchPlugins(1, !!query.trim(), true);
fetchPlugins(1, !!query.trim(), true, query);
},
[fetchPlugins],
);
@@ -292,33 +426,52 @@ function MarketPageContent({
setSortOption(value);
setCurrentPage(1);
setPlugins([]);
// fetchPlugins will be called by useEffect when sortOption changes
}, []);
// 组件筛选变化处理
const handleComponentFilterChange = useCallback((value: string) => {
setComponentFilter(value);
// Handle type filter change
const handleTypeFilterChange = useCallback((value: string) => {
setTypeFilter(value);
if (value !== 'plugin') {
setComponentFilter('all');
}
setCurrentPage(1);
setSelectedTags([]);
setPlugins([]);
// Update URL query param to keep it in sync
const params = new URLSearchParams(window.location.search);
if (value === 'all') {
params.delete('category');
params.delete('type');
} else {
params.set('category', value);
params.set('type', value);
}
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
// fetchPlugins will be called by useEffect when componentFilter changes
}, []);
// 当排序选项或组件筛选变化时重新加载数据
const handleComponentFilterChange = useCallback((value: string) => {
setComponentFilter(value);
setCurrentPage(1);
setPlugins([]);
if (value !== 'all') {
setTypeFilter('plugin');
const params = new URLSearchParams(window.location.search);
params.set('type', 'plugin');
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
}, []);
// 当排序选项或组件筛选或类型筛选变化时重新加载数据
useEffect(() => {
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter]);
}, [sortOption, componentFilter, typeFilter]);
// Tags 筛选变化时重新搜索
useEffect(() => {
@@ -336,13 +489,56 @@ function MarketPageContent({
// 处理安装插件
const handleInstallPlugin = useCallback(
async (author: string, pluginName: string) => {
async (cardVO: PluginMarketCardVO) => {
try {
// Fetch full plugin details to get PluginV4 object
if (cardVO.type === 'mcp' || cardVO.type === 'skill') {
// For MCP and Skill, directly pass the data - backend will fetch from Space
const pluginV4: PluginV4 = {
id: 0,
plugin_id: `${cardVO.author}/${cardVO.pluginName}`,
mcp_id:
cardVO.type === 'mcp'
? `${cardVO.author}/${cardVO.pluginName}`
: undefined,
skill_id:
cardVO.type === 'skill'
? `${cardVO.author}/${cardVO.pluginName}`
: undefined,
author: cardVO.author,
name: cardVO.pluginName,
label: { en_US: cardVO.label, zh_Hans: cardVO.label },
description: {
en_US: cardVO.description,
zh_Hans: cardVO.description,
},
icon: cardVO.iconURL,
repository: cardVO.githubURL,
tags: cardVO.tags || [],
install_count: cardVO.installCount,
latest_version: cardVO.version,
components: cardVO.components || {},
status: PluginV4Status.Live,
type: cardVO.type,
created_at: '',
updated_at: '',
};
installPlugin(pluginV4);
return;
}
// For plugin type, fetch full details via API
const response = await getCloudServiceClientSync().getPluginDetail(
author,
pluginName,
cardVO.author,
cardVO.pluginName,
);
if (!response?.plugin) {
console.error('Failed to install plugin: plugin not found', {
author: cardVO.author,
pluginName: cardVO.pluginName,
});
toast.error(t('market.installFailed'));
return;
}
const pluginV4: PluginV4 = response.plugin;
// Call the install function passed from parent
@@ -352,7 +548,7 @@ function MarketPageContent({
toast.error(t('market.installFailed'));
}
},
[plugins, installPlugin, t],
[installPlugin, t],
);
// 清理定时器
@@ -429,14 +625,18 @@ function MarketPageContent({
return (
<div className="h-full flex flex-col">
{/* Fixed header with search and sort controls */}
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
{/* Search box and Tags filter */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="relative w-full max-w-2xl">
{/* Fixed header section with search, sort, and status */}
<div className="flex-none px-3 sm:px-4 py-2 sm:py-4 space-y-4 sm:space-y-6 container mx-auto">
{/* 搜索、排序和筛选入口 */}
<div className="flex w-full items-center justify-center gap-2 sm:gap-3">
<div className="relative min-w-0 flex-1 lg:max-w-xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('market.searchPlaceholder')}
placeholder={
total > 0
? t('market.searchPlaceholderCount', { count: total })
: t('market.searchPlaceholder')
}
value={searchQuery}
onChange={(e) => handleSearchInputChange(e.target.value)}
onCompositionStart={() => {
@@ -448,109 +648,19 @@ function MarketPageContent({
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// Immediately search, clear debounce timer
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
handleSearch(searchQuery);
}
}}
className="pl-10 pr-4 text-sm sm:text-base"
className="min-w-0 pl-10 pr-4 text-sm sm:text-base"
/>
</div>
{/* Tags filter */}
<TagsFilter
availableTags={availableTags}
selectedTags={selectedTags}
onTagsChange={handleTagsChange}
/>
</div>
{/* Component filter and sort */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
{/* Component filter */}
<div className="flex flex-col sm:flex-row items-center gap-2 min-w-0 max-w-full">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.filterByComponent')}:
</span>
<div className="overflow-x-auto max-w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={componentFilter}
onValueChange={(value) => {
if (value) handleComponentFilterChange(value);
}}
className="justify-start flex-nowrap"
>
<ToggleGroupItem
value="all"
aria-label="All components"
className="text-xs sm:text-sm cursor-pointer"
>
{t('market.allComponents')}
</ToggleGroupItem>
<ToggleGroupItem
value="Tool"
aria-label="Tool"
className="text-xs sm:text-sm cursor-pointer"
>
<Wrench className="h-4 w-4 mr-1" />
{t('plugins.componentName.Tool')}
</ToggleGroupItem>
<ToggleGroupItem
value="Command"
aria-label="Command"
className="text-xs sm:text-sm cursor-pointer"
>
<Hash className="h-4 w-4 mr-1" />
{t('plugins.componentName.Command')}
</ToggleGroupItem>
<ToggleGroupItem
value="EventListener"
aria-label="EventListener"
className="text-xs sm:text-sm cursor-pointer"
>
<AudioWaveform className="h-4 w-4 mr-1" />
{t('plugins.componentName.EventListener')}
</ToggleGroupItem>
<ToggleGroupItem
value="KnowledgeEngine"
aria-label="KnowledgeEngine"
className="text-xs sm:text-sm cursor-pointer"
>
<Book className="h-4 w-4 mr-1" />
{t('plugins.componentName.KnowledgeEngine')}
</ToggleGroupItem>
<ToggleGroupItem
value="Parser"
aria-label="Parser"
className="text-xs sm:text-sm cursor-pointer"
>
<FileText className="h-4 w-4 mr-1" />
{t('plugins.componentName.Parser')}
</ToggleGroupItem>
<ToggleGroupItem
value="Page"
aria-label="Page"
className="text-xs sm:text-sm cursor-pointer"
>
<PanelTop className="h-4 w-4 mr-1" />
{t('plugins.componentName.Page')}
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
{/* Sort dropdown */}
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
<div className="flex shrink-0 items-center gap-2">
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
<SelectTrigger className="w-28 shrink-0 text-xs sm:w-40 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -561,35 +671,176 @@ function MarketPageContent({
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="relative shrink-0 px-3 sm:px-4"
>
<SlidersHorizontal className="h-4 w-4" />
<span className="hidden sm:inline">
{t('market.filters.more')}
</span>
{activeAdvancedFilters > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none text-primary-foreground">
{activeAdvancedFilters}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[320px] space-y-4">
<div>
<div className="text-sm font-medium">
{t('market.filters.advancedTitle')}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t('market.filters.advancedDescription')}
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t('market.filters.technicalType')}
</div>
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={typeFilter}
onValueChange={(value) => {
if (value) handleTypeFilterChange(value);
}}
className="flex flex-wrap justify-start gap-2"
>
{extensionTypeOptions.map((option) => {
const Icon = option.icon;
const count = typeCounts[option.value];
return (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
className="cursor-pointer text-xs"
>
{Icon && <Icon className="mr-1 h-3.5 w-3.5" />}
{option.label}
{typeof count === 'number' && (
<span className="ml-1 text-muted-foreground">
({count})
</span>
)}
</ToggleGroupItem>
);
})}
</ToggleGroup>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
{t('market.filterByComponent')}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex text-muted-foreground/70 hover:text-foreground"
aria-label={t('market.filterByComponentHint')}
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
{t('market.filterByComponentHint')}
</TooltipContent>
</Tooltip>
</div>
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={componentFilter}
onValueChange={(value) => {
if (value) handleComponentFilterChange(value);
}}
className="flex flex-wrap justify-start gap-2"
>
{componentOptions.map((option) => {
const Icon = option.icon;
return (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
className="cursor-pointer text-xs"
>
{Icon && <Icon className="mr-1 h-3.5 w-3.5" />}
{option.label}
</ToggleGroupItem>
);
})}
</ToggleGroup>
</div>
</PopoverContent>
</Popover>
{headerActions}
</div>
</div>
{/* Search results stats */}
{total > 0 && (
<div className="text-center text-muted-foreground text-sm">
{searchQuery
? t('market.searchResults', { count: total })
: t('market.totalPlugins', { count: total })}
{/* 用真实标签做快速筛选 —— 始终单行横向滚动,避免标签变多时换行错位 */}
<div className="relative mx-auto w-full max-w-4xl">
<div className="scrollbar-hide flex items-center gap-1.5 overflow-x-auto pb-1 pr-6">
<Button
type="button"
variant={selectedTags.length === 0 ? 'secondary' : 'ghost'}
size="sm"
className="h-7 shrink-0 px-2.5 text-xs"
onClick={() => handleTagsChange([])}
>
{t('market.allExtensions')}
</Button>
{availableTags.map((tag) => {
const selected = selectedTags.includes(tag.tag);
return (
<Button
key={tag.tag}
type="button"
variant={selected ? 'secondary' : 'ghost'}
size="sm"
className="h-7 shrink-0 px-2.5 text-xs"
onClick={() => {
const newTags = selected
? selectedTags.filter((t) => t !== tag.tag)
: [...selectedTags, tag.tag];
handleTagsChange(newTags);
}}
>
{tagNames[tag.tag] || tag.tag}
{selected && <X className="h-3 w-3" />}
</Button>
);
})}
</div>
)}
{/* 右侧渐隐,提示还有更多标签可横向滚动查看 */}
<div className="pointer-events-none absolute right-0 top-0 bottom-1 w-8 bg-gradient-to-l from-background to-transparent" />
</div>
</div>
{/* Scrollable content area */}
{/* Scrollable extension list section */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto px-3 sm:px-4"
className="flex-1 overflow-y-auto px-3 sm:px-4 pb-6 container mx-auto"
>
{/* Recommendation Lists */}
{/* 推荐列表(仅在无搜索/筛选时展示,混合插件/MCP/Skill) */}
{!searchQuery &&
typeFilter === 'all' &&
componentFilter === 'all' &&
selectedTags.length === 0 && (
<div className="pt-4">
<RecommendationLists
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
/>
</div>
<RecommendationLists
lists={recommendationLists}
tagNames={tagNames}
onInstall={handleInstallPlugin}
/>
)}
{isLoading ? (
@@ -611,7 +862,7 @@ function MarketPageContent({
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6 pb-6 pt-4">
<div className="grid gap-6 mt-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,24rem),1fr))]">
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
key={plugin.pluginId}
@@ -632,7 +883,9 @@ function MarketPageContent({
{/* No more data hint */}
{!hasMore && plugins.length > 0 && (
<div className="text-center text-muted-foreground py-6">
{t('market.allLoaded')}
{searchQuery
? t('market.allLoadedCount', { count: total })
: t('market.allLoaded')}
{' · '}
<a
href="https://github.com/langbot-app/langbot-plugin-demo/issues/new?template=plugin-request.yml"
@@ -654,8 +907,10 @@ function MarketPageContent({
// 主组件,包装在 Suspense 中
export default function MarketPage({
installPlugin,
headerActions,
}: {
installPlugin: (plugin: PluginV4) => void;
headerActions?: React.ReactNode;
}) {
return (
<Suspense
@@ -667,7 +922,10 @@ export default function MarketPage({
</div>
}
>
<MarketPageContent installPlugin={installPlugin} />
<MarketPageContent
installPlugin={installPlugin}
headerActions={headerActions}
/>
</Suspense>
);
}
@@ -1,5 +1,5 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
import { ChevronLeft, ChevronRight, Star, Pause, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
@@ -16,12 +16,22 @@ export interface RecommendationList {
plugins: PluginV4[];
}
// Match the main plugin grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4
// Match the main plugin grid: auto-fill columns with a 24rem minimum width
function pluginToVO(
plugin: PluginV4,
t: (key: string) => string,
): PluginMarketCardVO {
const cloudClient = getCloudServiceClientSync();
// Recommendation lists are mixed-type; resolve the icon per extension type,
// preferring an absolute external icon URL when the record carries one.
const iconURL = cloudClient.resolveMarketplaceIconURL(
plugin.type,
plugin.author,
plugin.name,
plugin.icon,
);
return new PluginMarketCardVO({
pluginId: plugin.author + ' / ' + plugin.name,
author: plugin.author,
@@ -30,14 +40,12 @@ function pluginToVO(
description:
extractI18nObject(plugin.description) || t('market.noDescription'),
installCount: plugin.install_count,
iconURL: getCloudServiceClientSync().getPluginIconURL(
plugin.author,
plugin.name,
),
iconURL,
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
type: plugin.type,
});
}
@@ -49,14 +57,25 @@ function RecommendationListRow({
}: {
list: RecommendationList;
tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void;
onInstall: (cardVO: PluginMarketCardVO) => void;
isLast: boolean;
}) {
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [perPage, setPerPage] = useState(4);
// Countdown progress to the next auto-advance, 0 → 1 over AUTO_ADVANCE_MS.
const [progress, setProgress] = useState(0);
const [paused, setPaused] = useState(false);
// Accumulated elapsed time in the current cycle and the timestamp of the last
// animation frame. Kept in refs so the interval reads live values without
// re-subscribing, and so pausing freezes progress in place.
const elapsedRef = useRef<number>(0);
const lastFrameRef = useRef<number>(Date.now());
const pausedRef = useRef<boolean>(false);
const gridRef = useRef<HTMLDivElement>(null);
const AUTO_ADVANCE_MS = 10000;
const plugins = (list.plugins || []).filter((plugin) => {
// Hide plugins that only contain deprecated KnowledgeRetriever components
const keys = Object.keys(plugin.components || {});
@@ -78,22 +97,65 @@ function RecommendationListRow({
return () => observer.disconnect();
}, [measureCols]);
// Auto-advance every 5 seconds
// Restart the countdown from zero. Called on manual navigation so the user's
// click resets the time-to-next-page indicator.
const resetCountdown = useCallback(() => {
elapsedRef.current = 0;
lastFrameRef.current = Date.now();
setProgress(0);
}, []);
const togglePaused = () => {
setPaused((prev) => {
const next = !prev;
pausedRef.current = next;
// Resync the frame clock on resume so the paused gap isn't counted.
lastFrameRef.current = Date.now();
return next;
});
};
// Auto-advance every AUTO_ADVANCE_MS, driving a smooth countdown ring. The
// interval accumulates elapsed time from refs, so resetCountdown() restarts
// the cycle on manual navigation and pause freezes it without re-creating the
// interval.
useEffect(() => {
if (plugins.length <= perPage) return;
resetCountdown();
const timer = setInterval(() => {
setPage((p) => {
const tp = Math.max(1, Math.ceil(plugins.length / perPage));
return p >= tp - 1 ? 0 : p + 1;
});
}, 5000);
const now = Date.now();
const delta = now - lastFrameRef.current;
lastFrameRef.current = now;
if (pausedRef.current) return;
elapsedRef.current += delta;
if (elapsedRef.current >= AUTO_ADVANCE_MS) {
elapsedRef.current = 0;
setProgress(0);
setPage((p) => {
const tp = Math.max(1, Math.ceil(plugins.length / perPage));
return p >= tp - 1 ? 0 : p + 1;
});
} else {
setProgress(elapsedRef.current / AUTO_ADVANCE_MS);
}
}, 50);
return () => clearInterval(timer);
}, [plugins.length, perPage]);
}, [plugins.length, perPage, resetCountdown]);
const totalPages = Math.max(1, Math.ceil(plugins.length / perPage));
const safePage = Math.min(page, totalPages - 1);
if (safePage !== page) setPage(safePage);
const goPrev = () => {
setPage((p) => Math.max(0, p - 1));
resetCountdown();
};
const goNext = () => {
setPage((p) => Math.min(totalPages - 1, p + 1));
resetCountdown();
};
const start = safePage * perPage;
const visiblePlugins = plugins.slice(start, start + perPage);
@@ -113,7 +175,7 @@ function RecommendationListRow({
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
onClick={goPrev}
disabled={safePage === 0}
className="h-7 w-7 p-0"
>
@@ -122,10 +184,66 @@ function RecommendationListRow({
<span className="text-xs text-muted-foreground px-1">
{safePage + 1} / {totalPages}
</span>
{/* Auto-advance countdown ring doubles as a pause/resume toggle.
The ring fills as the next flip approaches; click to pause. */}
<button
type="button"
onClick={togglePaused}
title={
paused
? t('market.recommendation.resume')
: t('market.recommendation.pause')
}
aria-label={
paused
? t('market.recommendation.resume')
: t('market.recommendation.pause')
}
className="relative inline-flex h-7 w-7 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
>
<svg
width="18"
height="18"
viewBox="0 0 16 16"
className="-rotate-90 shrink-0"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-muted-foreground/25"
/>
<circle
cx="8"
cy="8"
r="6"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className={
paused ? 'text-muted-foreground/50' : 'text-yellow-500'
}
strokeDasharray={2 * Math.PI * 6}
strokeDashoffset={2 * Math.PI * 6 * (1 - progress)}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center">
{paused ? (
<Play className="h-2.5 w-2.5" />
) : (
<Pause className="h-2.5 w-2.5" />
)}
</span>
</button>
<Button
variant="ghost"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
onClick={goNext}
disabled={safePage >= totalPages - 1}
className="h-7 w-7 p-0"
>
@@ -136,7 +254,7 @@ function RecommendationListRow({
</div>
<div
ref={gridRef}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6"
className="grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,24rem),1fr))]"
>
{visiblePlugins.map((plugin) => (
<PluginMarketCardComponent
@@ -161,7 +279,7 @@ export function RecommendationLists({
}: {
lists: RecommendationList[];
tagNames: Record<string, string>;
onInstall: (author: string, pluginName: string) => void;
onInstall: (cardVO: PluginMarketCardVO) => void;
}) {
if (!lists || lists.length === 0) return null;
@@ -44,7 +44,7 @@ export function TagsFilter({
return (
<Select open={open} onOpenChange={setOpen}>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="w-[140px] cursor-pointer">
<div className="flex items-center gap-2 w-full">
<TagIcon className="h-4 w-4 flex-shrink-0" />
{selectedTags.length === 0 ? (
@@ -1,16 +1,15 @@
import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '../PluginComponentList';
import { Badge } from '@/components/ui/badge';
import { Info, Package, ExternalLink } from 'lucide-react';
import {
Wrench,
AudioWaveform,
Hash,
Download,
ExternalLink,
Book,
FileText,
} from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
export default function PluginMarketCardComponent({
@@ -19,15 +18,40 @@ export default function PluginMarketCardComponent({
tagNames = {},
}: {
cardVO: PluginMarketCardVO;
onInstall?: (author: string, pluginName: string) => void;
onInstall?: (cardVO: PluginMarketCardVO) => void;
tagNames?: Record<string, string>;
}) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const [visibleTags, setVisibleTags] = useState(2);
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
const isDeprecated = (() => {
if (!cardVO.components) return false;
const keys = Object.keys(cardVO.components);
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
const showTypeBadge = cardVO.type;
const typeLabel =
cardVO.type === 'mcp'
? t('market.typeMCP')
: cardVO.type === 'skill'
? t('market.typeSkill')
: t('market.typePlugin');
const typeDotClass =
cardVO.type === 'mcp'
? 'bg-sky-500/70'
: cardVO.type === 'skill'
? 'bg-emerald-500/70'
: 'bg-violet-500/70';
useEffect(() => {
setIconFailed(!cardVO.iconURL);
}, [cardVO.iconURL]);
// Measure how many tags fit in the bottom row
useEffect(() => {
const tags = cardVO.tags;
if (!bottomRef.current || !tags || tags.length === 0) return;
@@ -61,44 +85,43 @@ export default function PluginMarketCardComponent({
}, [cardVO.tags]);
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
function handleInstallClick(e: React.MouseEvent) {
e.stopPropagation();
if (onInstall) {
onInstall(cardVO.author, cardVO.pluginName);
}
}
function handleViewDetailsClick(e: React.MouseEvent) {
e.stopPropagation();
const detailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
window.open(detailUrl, '_blank');
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-4 h-4" />,
EventListener: <AudioWaveform className="w-4 h-4" />,
Command: <Hash className="w-4 h-4" />,
KnowledgeEngine: <Book className="w-4 h-4" />,
Parser: <FileText className="w-4 h-4" />,
const handleInstallClick = () => {
onInstall?.(cardVO);
};
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-3 sm:p-[1rem] hover:border-[#a1a1aa] dark:hover:border-[#3f3f46] transition-all duration-200 dark:bg-[#1f1f22] relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
role="button"
tabIndex={0}
aria-label={t('market.installCard', { name: cardVO.label })}
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
onClick={handleInstallClick}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleInstallClick();
}
}}
>
<div className="w-full h-full flex flex-col justify-between gap-3">
{/* 上部分:插件信息 */}
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
<img
src={cardVO.iconURL}
alt="plugin icon"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%]"
/>
<div className="w-full h-full flex flex-col justify-between">
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0 flex-1 overflow-hidden">
{iconFailed ? (
<div className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%] border bg-muted text-muted-foreground flex items-center justify-center">
<Package className="w-6 h-6 sm:w-8 sm:h-8" />
</div>
) : (
<img
src={cardVO.iconURL}
alt="plugin icon"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%] object-cover"
loading="lazy"
decoding="async"
fetchPriority="low"
onError={() => setIconFailed(true)}
/>
)}
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 overflow-hidden">
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 pr-1 overflow-hidden">
<div className="flex flex-col items-start justify-start w-full min-w-0">
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
{cardVO.pluginId}
@@ -107,6 +130,44 @@ export default function PluginMarketCardComponent({
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
{cardVO.label}
</div>
{isDeprecated && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger
asChild
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Badge
variant="outline"
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
>
{t('market.deprecated')}
<Info className="w-2.5 h-2.5" />
</Badge>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-[240px] text-xs"
>
{t('market.deprecatedTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{showTypeBadge && (
<Badge
variant="outline"
className="h-4 max-w-[4.5rem] flex-shrink-0 gap-1 border-border/60 bg-muted/30 px-1.5 py-0 text-[0.58rem] font-normal text-muted-foreground"
>
<span
className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${typeDotClass}`}
/>
<span className="truncate">{typeLabel}</span>
</Badge>
)}
</div>
</div>
@@ -115,31 +176,53 @@ export default function PluginMarketCardComponent({
</div>
</div>
<div className="flex flex-row items-start justify-center gap-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-start justify-center gap-1 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
title={t('market.viewDetails')}
aria-label={t('market.viewDetails')}
className="h-7 w-7 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
window.open(pluginDetailUrl, '_blank');
}}
>
<ExternalLink className="h-4 w-4" />
</Button>
{cardVO.githubURL && (
<svg
className="w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
<Button
type="button"
variant="ghost"
size="icon"
title="GitHub"
aria-label="GitHub"
className="h-7 w-7 rounded-md text-foreground hover:bg-muted hover:text-foreground"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(cardVO.githubURL, '_blank');
}}
>
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
</svg>
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12.001 2C6.47598 2 2.00098 6.475 2.00098 12C2.00098 16.425 4.86348 20.1625 8.83848 21.4875C9.33848 21.575 9.52598 21.275 9.52598 21.0125C9.52598 20.775 9.51348 19.9875 9.51348 19.15C7.00098 19.6125 6.35098 18.5375 6.15098 17.975C6.03848 17.6875 5.55098 16.8 5.12598 16.5625C4.77598 16.375 4.27598 15.9125 5.11348 15.9C5.90098 15.8875 6.46348 16.625 6.65098 16.925C7.55098 18.4375 8.98848 18.0125 9.56348 17.75C9.65098 17.1 9.91348 16.6625 10.201 16.4125C7.97598 16.1625 5.65098 15.3 5.65098 11.475C5.65098 10.3875 6.03848 9.4875 6.67598 8.7875C6.57598 8.5375 6.22598 7.5125 6.77598 6.1375C6.77598 6.1375 7.61348 5.875 9.52598 7.1625C10.326 6.9375 11.176 6.825 12.026 6.825C12.876 6.825 13.726 6.9375 14.526 7.1625C16.4385 5.8625 17.276 6.1375 17.276 6.1375C17.826 7.5125 17.476 8.5375 17.376 8.7875C18.0135 9.4875 18.401 10.375 18.401 11.475C18.401 15.3125 16.0635 16.1625 13.8385 16.4125C14.201 16.725 14.5135 17.325 14.5135 18.2625C14.5135 19.6 14.501 20.675 14.501 21.0125C14.501 21.275 14.6885 21.5875 15.1885 21.4875C19.259 20.1133 21.9999 16.2963 22.001 12C22.001 6.475 17.526 2 12.001 2Z"></path>
</svg>
</Button>
)}
</div>
</div>
{/* 下部分:下载量、标签和组件列表 */}
<div
ref={bottomRef}
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
>
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
{/* 下载数量 */}
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
@@ -158,7 +241,6 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* Tags - adaptive */}
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
{cardVO.tags.slice(0, visibleTags).map((tag) => (
@@ -197,52 +279,20 @@ export default function PluginMarketCardComponent({
)}
</div>
{/* 组件列表 */}
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
<div className="flex flex-row items-center gap-1">
{Object.entries(cardVO.components).map(([kind, count]) => (
<Badge
key={kind}
variant="outline"
className="flex items-center gap-1"
>
{kindIconMap[kind]}
<span className="ml-1">{count}</span>
</Badge>
))}
<div className="flex flex-row items-center gap-1 flex-shrink-0">
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={false}
useBadge={true}
t={t}
responsive={false}
/>
</div>
)}
</div>
</div>
{/* Hover overlay with action buttons */}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={handleInstallClick}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<Download className="w-4 h-4" />
{t('market.install')}
</Button>
<Button
onClick={handleViewDetailsClick}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<ExternalLink className="w-4 h-4" />
{t('market.viewDetails')}
</Button>
</div>
</div>
);
}
@@ -10,6 +10,7 @@ export interface IPluginMarketCardVO {
version: string;
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -24,6 +25,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
version: string;
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -37,5 +39,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.version = prop.version;
this.components = prop.components;
this.tags = prop.tags;
this.type = prop.type;
}
}
@@ -1,29 +0,0 @@
import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
export class MCPCardVO {
name: string;
mode: 'stdio' | 'sse' | 'http';
enable: boolean;
status: MCPSessionStatus;
tools: number;
error?: string;
constructor(data: MCPServer) {
this.name = data.name;
this.mode = data.mode;
this.enable = data.enable;
// Determine status from runtime_info
if (!data.runtime_info) {
this.status = MCPSessionStatus.ERROR;
this.tools = 0;
} else if (data.runtime_info.status === MCPSessionStatus.CONNECTED) {
this.status = data.runtime_info.status;
this.tools = data.runtime_info.tool_count || 0;
} else {
this.status = data.runtime_info.status;
this.tools = 0;
this.error = data.runtime_info.error_message;
}
}
}
@@ -1,112 +0,0 @@
import { useEffect, useState, useRef } from 'react';
import MCPCardComponent from '@/app/home/plugins/mcp-server/mcp-card/MCPCardComponent';
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
import { useTranslation } from 'react-i18next';
import { MCPSessionStatus } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
export default function MCPComponent({
onEditServer,
}: {
askInstallServer?: (githubURL: string) => void;
onEditServer?: (serverName: string) => void;
}) {
const { t } = useTranslation();
const [installedServers, setInstalledServers] = useState<MCPCardVO[]>([]);
const [loading, setLoading] = useState(false);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
fetchInstalledServers();
return () => {
// Cleanup: clear polling interval when component unmounts
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
}
};
}, []);
// Check if any enabled server is connecting and start/stop polling accordingly
useEffect(() => {
const hasConnecting = installedServers.some(
(server) =>
server.enable && server.status === MCPSessionStatus.CONNECTING,
);
if (hasConnecting && !pollingIntervalRef.current) {
// Start polling every 3 seconds
pollingIntervalRef.current = setInterval(() => {
fetchInstalledServers();
}, 3000);
} else if (!hasConnecting && pollingIntervalRef.current) {
// Stop polling when no enabled server is connecting
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [installedServers]);
function fetchInstalledServers() {
setLoading(true);
httpClient
.getMCPServers()
.then((resp) => {
const servers = resp.servers.map((server) => new MCPCardVO(server));
setInstalledServers(servers);
setLoading(false);
})
.catch((error) => {
console.error('Failed to fetch MCP servers:', error);
setLoading(false);
});
}
return (
<div className="w-full h-full">
{/* Server list */}
<div className="w-full h-full px-[0.8rem] pt-[0rem]">
{loading ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
{t('mcp.loading')}
</div>
) : installedServers.length === 0 ? (
<div className="flex flex-col items-center justify-center text-gray-500 min-h-[60vh] w-full gap-2">
<svg
className="h-[3rem] w-[3rem]"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
</svg>
<div className="text-lg mb-2">{t('mcp.noServerInstalled')}</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 pt-[2rem] pb-6">
{installedServers.map((server, index) => (
<div key={`${server.name}-${index}`}>
<MCPCardComponent
cardVO={server}
onCardClick={() => {
if (onEditServer) {
onEditServer(server.name);
}
}}
onRefresh={fetchInstalledServers}
/>
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -1,178 +0,0 @@
import { MCPCardVO } from '@/app/home/plugins/mcp-server/MCPCardVO';
import { useState, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';
import { MCPSessionStatus } from '@/app/infra/entities/api';
export default function MCPCardComponent({
cardVO,
onCardClick,
onRefresh,
}: {
cardVO: MCPCardVO;
onCardClick: () => void;
onRefresh: () => void;
}) {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(cardVO.enable);
const [switchEnable, setSwitchEnable] = useState(true);
const [testing, setTesting] = useState(false);
const [toolsCount, setToolsCount] = useState(cardVO.tools);
const [status, setStatus] = useState(cardVO.status);
useEffect(() => {
setStatus(cardVO.status);
setToolsCount(cardVO.tools);
setEnabled(cardVO.enable);
}, [cardVO.status, cardVO.tools, cardVO.enable]);
function handleEnable(checked: boolean) {
setSwitchEnable(false);
httpClient
.toggleMCPServer(cardVO.name, checked)
.then(() => {
setEnabled(checked);
toast.success(t('mcp.saveSuccess'));
onRefresh();
setSwitchEnable(true);
})
.catch((err) => {
toast.error(t('mcp.modifyFailed') + err.msg);
setSwitchEnable(true);
});
}
function handleTest(e: React.MouseEvent) {
e.stopPropagation();
setTesting(true);
httpClient
.testMCPServer(cardVO.name, {})
.then((resp) => {
const taskId = resp.task_id;
const interval = setInterval(() => {
httpClient.getAsyncTask(taskId).then((taskResp) => {
if (taskResp.runtime.done) {
clearInterval(interval);
setTesting(false);
if (taskResp.runtime.exception) {
toast.error(
t('mcp.refreshFailed') + taskResp.runtime.exception,
);
} else {
toast.success(t('mcp.refreshSuccess'));
}
// Refresh to get updated runtime_info
onRefresh();
}
});
}, 1000);
})
.catch((err) => {
toast.error(t('mcp.refreshFailed') + err.msg);
setTesting(false);
});
}
return (
<div
className="w-[100%] h-[10rem] bg-white dark:bg-[#1f1f22] rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-[1.2rem] cursor-pointer transition-all duration-200 hover:border-[#a1a1aa] dark:hover:border-[#3f3f46]"
onClick={onCardClick}
>
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="64"
height="64"
fill="rgba(70,146,221,1)"
>
<path d="M17.6567 14.8284L16.2425 13.4142L17.6567 12C19.2188 10.4379 19.2188 7.90524 17.6567 6.34314C16.0946 4.78105 13.5619 4.78105 11.9998 6.34314L10.5856 7.75736L9.17139 6.34314L10.5856 4.92893C12.9287 2.58578 16.7277 2.58578 19.0709 4.92893C21.414 7.27208 21.414 11.0711 19.0709 13.4142L17.6567 14.8284ZM14.8282 17.6569L13.414 19.0711C11.0709 21.4142 7.27189 21.4142 4.92875 19.0711C2.5856 16.7279 2.5856 12.9289 4.92875 10.5858L6.34296 9.17157L7.75717 10.5858L6.34296 12C4.78086 13.5621 4.78086 16.0948 6.34296 17.6569C7.90506 19.2189 10.4377 19.2189 11.9998 17.6569L13.414 16.2426L14.8282 17.6569ZM14.8282 7.75736L16.2425 9.17157L9.17139 16.2426L7.75717 14.8284L14.8282 7.75736Z"></path>
</svg>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start gap-[0.3rem]">
<div className="flex flex-row items-center gap-[0.5rem]">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
{cardVO.name}
</div>
<Badge variant="secondary" className="text-[0.65rem] px-1.5 py-0">
{cardVO.mode.toUpperCase()}
</Badge>
</div>
</div>
<div className="w-full flex flex-row items-start justify-start gap-[0.6rem]">
{!enabled ? (
// 未启用 - 橙色
<div className="flex flex-row items-center gap-[0.4rem]">
<Ban className="w-4 h-4 text-orange-500 dark:text-orange-400" />
<div className="text-sm text-orange-500 dark:text-orange-400 font-medium">
{t('mcp.statusDisabled')}
</div>
</div>
) : status === MCPSessionStatus.CONNECTED ? (
// 连接成功 - 显示工具数量
<div className="flex h-full flex-row items-center justify-center gap-[0.4rem]">
<Wrench className="w-5 h-5" />
<div className="text-base text-black dark:text-[#f0f0f0] font-medium">
{t('mcp.toolCount', { count: toolsCount })}
</div>
</div>
) : status === MCPSessionStatus.CONNECTING ? (
// 连接中 - 蓝色加载
<div className="flex flex-row items-center gap-[0.4rem]">
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
{t('mcp.connecting')}
</div>
</div>
) : (
// 连接失败 - 红色
<div className="flex flex-row items-center gap-[0.4rem]">
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
{t('mcp.connectionFailedStatus')}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-col items-center justify-between h-full">
<div
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<Switch
className="cursor-pointer"
checked={enabled}
onCheckedChange={handleEnable}
disabled={!switchEnable}
/>
</div>
<div className="flex items-center justify-center gap-[0.4rem]">
<Button
variant="ghost"
size="sm"
className="p-1 h-8 w-8"
onClick={(e) => handleTest(e)}
disabled={testing}
>
<RefreshCcw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -1,66 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
interface MCPDeleteConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverName: string | null;
onSuccess?: () => void;
}
export default function MCPDeleteConfirmDialog({
open,
onOpenChange,
serverName,
onSuccess,
}: MCPDeleteConfirmDialogProps) {
const { t } = useTranslation();
async function handleDelete() {
if (!serverName) return;
try {
await httpClient.deleteMCPServer(serverName);
toast.success(t('mcp.deleteSuccess'));
onOpenChange(false);
if (onSuccess) {
onSuccess();
}
} catch (error) {
console.error('Failed to delete server:', error);
toast.error(t('mcp.deleteFailed'));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>
</DialogHeader>
<DialogDescription>{t('mcp.confirmDeleteServer')}</DialogDescription>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={handleDelete}>
{t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,915 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Resolver, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
MCPServerRuntimeInfo,
MCPTool,
MCPServer,
MCPSessionStatus,
MCPServerExtraArgsSSE,
MCPServerExtraArgsHttp,
MCPServerExtraArgsStdio,
} from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
// Status Display Component - 在测试中、连接中或连接失败时使用
function StatusDisplay({
testing,
runtimeInfo,
t,
}: {
testing: boolean;
runtimeInfo: MCPServerRuntimeInfo;
t: (key: string) => string;
}) {
if (testing) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.testing')}</span>
</div>
);
}
// 连接中
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
return (
<div className="flex items-center gap-2 text-blue-600">
<svg
className="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span className="font-medium">{t('mcp.connecting')}</span>
</div>
);
}
// 连接失败
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-red-600">
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="font-medium">{t('mcp.connectionFailed')}</span>
</div>
{runtimeInfo.error_message && (
<div className="text-sm text-red-500 pl-7">
{runtimeInfo.error_message}
</div>
)}
</div>
);
}
// Tools List Component
function ToolsList({ tools }: { tools: MCPTool[] }) {
return (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{tools.map((tool, index) => (
<Card key={index} className="py-3 shadow-none">
<CardHeader>
<CardTitle className="text-sm">{tool.name}</CardTitle>
{tool.description && (
<CardDescription className="text-xs">
{tool.description}
</CardDescription>
)}
</CardHeader>
</Card>
))}
</div>
);
}
const getFormSchema = (t: (key: string) => string) =>
z
.object({
name: z
.string({ required_error: t('mcp.nameRequired') })
.min(1, { message: t('mcp.nameRequired') }),
mode: z.enum(['sse', 'stdio', 'http']),
timeout: z
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
.default(30),
ssereadtimeout: z
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
.positive({ message: t('mcp.timeoutMustBePositive') })
.default(300),
url: z.string().optional(),
command: z.string().optional(),
args: z.array(z.object({ value: z.string() })).optional(),
extra_args: z
.array(
z.object({
key: z.string(),
type: z.enum(['string', 'number', 'boolean']),
value: z.string(),
}),
)
.optional(),
})
.superRefine((data, ctx) => {
if (data.mode === 'sse' || data.mode === 'http') {
if (!data.url || data.url.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('mcp.urlRequired'),
path: ['url'],
});
}
} else if (data.mode === 'stdio') {
if (!data.command || data.command.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('mcp.commandRequired'),
path: ['command'],
});
}
}
});
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
timeout: number;
ssereadtimeout: number;
};
interface MCPFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
serverName?: string | null;
isEditMode?: boolean;
onSuccess?: () => void;
onDelete?: () => void;
}
export default function MCPFormDialog({
open,
onOpenChange,
serverName,
isEditMode = false,
onSuccess,
onDelete,
}: MCPFormDialogProps) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
defaultValues: {
name: '',
mode: 'sse',
url: '',
command: '',
args: [],
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
},
});
const [extraArgs, setExtraArgs] = useState<
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
>([]);
const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]);
const [mcpTesting, setMcpTesting] = useState(false);
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
null,
);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const watchMode = form.watch('mode');
// Load server data when editing
useEffect(() => {
if (open && isEditMode && serverName) {
loadServerForEdit(serverName);
} else if (open && !isEditMode) {
// Reset form when creating new server
form.reset({
name: '',
mode: 'sse',
url: '',
command: '',
args: [],
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
});
setExtraArgs([]);
setStdioArgs([]);
setRuntimeInfo(null);
}
// Cleanup polling interval when dialog closes
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [open, isEditMode, serverName]);
// Poll for updates when runtime_info status is CONNECTING
useEffect(() => {
if (
!open ||
!isEditMode ||
!serverName ||
!runtimeInfo ||
runtimeInfo.status !== MCPSessionStatus.CONNECTING
) {
// Stop polling if conditions are not met
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
return;
}
// Start polling if not already running
if (!pollingIntervalRef.current) {
pollingIntervalRef.current = setInterval(() => {
loadServerForEdit(serverName);
}, 3000);
}
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
};
}, [open, isEditMode, serverName, runtimeInfo?.status]);
async function loadServerForEdit(serverName: string) {
try {
const resp = await httpClient.getMCPServer(serverName);
const server = resp.server ?? resp;
form.setValue('name', server.name);
form.setValue('mode', server.mode);
if (server.mode === 'sse' || server.mode === 'http') {
form.setValue('url', server.extra_args.url);
form.setValue('timeout', server.extra_args.timeout);
if (server.mode === 'sse') {
form.setValue('ssereadtimeout', server.extra_args.ssereadtimeout);
}
if (server.extra_args.headers) {
const headers = Object.entries(server.extra_args.headers).map(
([key, value]) => ({
key,
type: 'string' as const,
value: String(value),
}),
);
setExtraArgs(headers);
form.setValue('extra_args', headers);
}
} else if (server.mode === 'stdio') {
form.setValue('command', server.extra_args.command);
const args = (server.extra_args.args || []).map((arg: string) => ({
value: arg,
}));
setStdioArgs(args);
form.setValue('args', args);
if (server.extra_args.env) {
const envs = Object.entries(server.extra_args.env).map(
([key, value]) => ({
key,
type: 'string' as const,
value: String(value),
}),
);
setExtraArgs(envs);
form.setValue('extra_args', envs);
}
}
if (server.runtime_info) {
setRuntimeInfo(server.runtime_info);
} else {
setRuntimeInfo(null);
}
} catch (error) {
console.error('Failed to load server:', error);
toast.error(t('mcp.loadFailed'));
}
}
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
try {
let serverConfig: MCPServer;
if (value.mode === 'sse' || value.mode === 'http') {
const headers: Record<string, string> = {};
value.extra_args?.forEach((arg) => {
headers[arg.key] = String(arg.value);
});
if (value.mode === 'sse') {
serverConfig = {
name: value.name,
mode: 'sse',
enable: true,
extra_args: {
url: value.url!,
headers: headers,
timeout: value.timeout,
ssereadtimeout: value.ssereadtimeout,
},
};
} else {
serverConfig = {
name: value.name,
mode: 'http',
enable: true,
extra_args: {
url: value.url!,
headers: headers,
timeout: value.timeout,
},
};
}
} else {
// Convert extra_args to env
const env: Record<string, string> = {};
value.extra_args?.forEach((arg) => {
env[arg.key] = String(arg.value);
});
// Convert args object array to string array
const args = value.args?.map((arg) => arg.value) || [];
serverConfig = {
name: value.name,
mode: 'stdio',
enable: true,
extra_args: {
command: value.command!,
args: args,
env: env,
},
};
}
if (isEditMode && serverName) {
await httpClient.updateMCPServer(serverName, serverConfig);
toast.success(t('mcp.updateSuccess'));
} else {
await httpClient.createMCPServer(serverConfig);
toast.success(t('mcp.createSuccess'));
}
handleDialogClose(false);
onSuccess?.();
} catch (error) {
console.error('Failed to save MCP server:', error);
const errMsg = (error as CustomApiError).msg || '';
toast.error(
(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')) + errMsg,
);
}
}
async function testMcp() {
setMcpTesting(true);
try {
const mode = form.getValues('mode');
let extraArgsData:
| MCPServerExtraArgsSSE
| MCPServerExtraArgsHttp
| MCPServerExtraArgsStdio;
if (mode === 'sse') {
extraArgsData = {
url: form.getValues('url')!,
timeout: form.getValues('timeout'),
headers: Object.fromEntries(
extraArgs.map((arg) => [arg.key, arg.value]),
),
ssereadtimeout: form.getValues('ssereadtimeout'),
};
} else if (mode === 'http') {
extraArgsData = {
url: form.getValues('url')!,
timeout: form.getValues('timeout'),
headers: Object.fromEntries(
extraArgs.map((arg) => [arg.key, arg.value]),
),
};
} else {
extraArgsData = {
command: form.getValues('command')!,
args: stdioArgs.map((arg) => arg.value),
env: Object.fromEntries(extraArgs.map((arg) => [arg.key, arg.value])),
};
}
const { task_id } = await httpClient.testMCPServer('_', {
name: form.getValues('name'),
mode: mode,
enable: true,
extra_args: extraArgsData,
} as MCPServer);
if (!task_id) {
throw new Error(t('mcp.noTaskId'));
}
const interval = setInterval(async () => {
try {
const taskResp = await httpClient.getAsyncTask(task_id);
if (taskResp.runtime?.done) {
clearInterval(interval);
setMcpTesting(false);
if (taskResp.runtime.exception) {
const errorMsg =
taskResp.runtime.exception || t('mcp.unknownError');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
setRuntimeInfo({
status: MCPSessionStatus.ERROR,
error_message: errorMsg,
tool_count: 0,
tools: [],
});
} else {
if (isEditMode) {
await loadServerForEdit(form.getValues('name'));
}
toast.success(t('mcp.testSuccess'));
}
}
} catch (err) {
clearInterval(interval);
setMcpTesting(false);
const errorMsg =
(err as CustomApiError).msg || t('mcp.getTaskFailed');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
}
}, 1000);
} catch (err) {
setMcpTesting(false);
const errorMsg = (err as Error).message || t('mcp.unknownError');
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
}
}
const addExtraArg = () => {
const newArgs = [
...extraArgs,
{ key: '', type: 'string' as const, value: '' },
];
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs);
};
const removeExtraArg = (index: number) => {
const newArgs = extraArgs.filter((_, i) => i !== index);
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs);
};
const updateExtraArg = (
index: number,
field: 'key' | 'type' | 'value',
value: string,
) => {
const newArgs = [...extraArgs];
newArgs[index] = { ...newArgs[index], [field]: value };
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs);
};
const addStdioArg = () => {
const newArgs = [...stdioArgs, { value: '' }];
setStdioArgs(newArgs);
form.setValue('args', newArgs);
};
const removeStdioArg = (index: number) => {
const newArgs = stdioArgs.filter((_, i) => i !== index);
setStdioArgs(newArgs);
form.setValue('args', newArgs);
};
const updateStdioArg = (index: number, value: string) => {
const newArgs = [...stdioArgs];
newArgs[index] = { value };
setStdioArgs(newArgs);
form.setValue('args', newArgs);
};
const handleDialogClose = (open: boolean) => {
onOpenChange(open);
if (!open) {
form.reset();
setExtraArgs([]);
setStdioArgs([]);
setRuntimeInfo(null);
}
};
return (
<Dialog open={open} onOpenChange={handleDialogClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
</DialogTitle>
</DialogHeader>
{isEditMode && runtimeInfo && (
<div className="mb-0 space-y-3">
{/* 测试中或连接失败时显示状态 */}
{(mcpTesting ||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
<div className="p-3 rounded-lg border">
<StatusDisplay
testing={mcpTesting}
runtimeInfo={runtimeInfo}
t={t}
/>
</div>
)}
{/* 连接成功时只显示工具列表 */}
{!mcpTesting &&
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
runtimeInfo.tools?.length > 0 && (
<>
<div className="text-sm font-medium">
{t('mcp.toolCount', {
count: runtimeInfo.tools?.length || 0,
})}
</div>
<ToolsList tools={runtimeInfo.tools} />
</>
)}
</div>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-4"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.serverMode')}</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('mcp.selectMode')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">{t('mcp.http')}</SelectItem>
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{(watchMode === 'sse' || watchMode === 'http') && (
<>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.url')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.timeout')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'sse' && (
<FormField
control={form.control}
name="ssereadtimeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.sseTimeoutDescription')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{watchMode === 'stdio' && (
<>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.command')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>{t('mcp.args')}</FormLabel>
<div className="space-y-2">
{stdioArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('mcp.args')}
value={arg.value}
onChange={(e) =>
updateStdioArg(index, e.target.value)
}
/>
<button
type="button"
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeStdioArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={addStdioArg}
>
{t('mcp.addArgument')}
</Button>
</div>
</FormItem>
</>
)}
<FormItem>
<FormLabel>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.headers')
: t('mcp.env')}
</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
{/* Only show type select for SSE headers if needed, but usually headers are strings. Env vars are definitely strings.
The original code had type selector. Let's keep it for compatibility or remove if not needed.
Headers are strings. Env vars are strings.
Let's hide the type selector as it was confusing anyway, or force it to string.
*/}
{/* <Select
value={arg.type}
onValueChange={(value) =>
updateExtraArg(index, 'type', value)
}
>
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.type')} />
</SelectTrigger>
<SelectContent className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectItem value="string">
{t('models.string')}
</SelectItem>
</SelectContent>
</Select> */}
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<button
type="button"
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeExtraArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.addHeader')
: t('mcp.addEnvVar')}
</Button>
</div>
<FormDescription>
{t('mcp.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
<DialogFooter>
{isEditMode && onDelete && (
<Button
type="button"
variant="destructive"
onClick={onDelete}
>
{t('common.delete')}
</Button>
)}
<Button type="submit">
{isEditMode ? t('common.save') : t('common.submit')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => testMcp()}
disabled={mcpTesting}
>
{t('common.test')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => handleDialogClose(false)}
>
{t('common.cancel')}
</Button>
</DialogFooter>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -14,8 +14,15 @@
padding-top: 2rem;
padding-bottom: 2rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(30rem, 1fr));
gap: 2rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
gap: 1.25rem;
justify-items: stretch;
align-items: start;
}
@media (min-width: 640px) {
.pluginListContainer {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 28rem), 1fr));
gap: 2rem;
}
}
@@ -0,0 +1,220 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { httpClient } from '@/app/infra/http/HttpClient';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { Sparkles, Trash2 } from 'lucide-react';
export default function SkillDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id);
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('skills.createSkill'));
} else {
setDetailEntityName(skill?.name ?? id);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, setDetailEntityName, skill, t]);
function handleImportedSkills(skillNames: string[]) {
void refreshSkills();
const primarySkill = skillNames[0];
if (primarySkill) {
navigate(`/home/skills?id=${encodeURIComponent(primarySkill)}`);
return;
}
navigate('/home/skills');
}
function handleSkillUpdated() {
void refreshSkills();
}
async function confirmDelete() {
try {
await httpClient.deleteSkill(id);
toast.success(t('skills.deleteSuccess'));
setShowDeleteConfirm(false);
void refreshSkills();
navigate('/home/skills');
} catch (error) {
toast.error(t('skills.deleteError') + String(error));
}
}
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-3">
<h1 className="truncate text-xl font-semibold">
{t('skills.createSkill')}
</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Sparkles className="size-3.5" />
{t('skills.title')}
</Badge>
</div>
</div>
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={() => {}}
/>
</div>
</div>
);
}
const editActions = (
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{t('skills.dangerZone')}
</CardTitle>
<CardDescription>{t('skills.dangerZoneDescription')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">{t('skills.delete')}</p>
<p className="text-sm text-muted-foreground">
{t('skills.deleteConfirmation')}
</p>
</div>
<Button
variant="destructive"
type="button"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="shrink-0"
>
<Trash2 className="mr-1.5 size-4" />
{t('common.delete')}
</Button>
</div>
</CardContent>
</Card>
);
return (
<>
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-col gap-3 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 items-center gap-3">
<h1 className="truncate text-xl font-semibold">
{skill?.name ?? id}
</h1>
<Badge variant="outline" className="shrink-0 text-[0.7rem]">
<Sparkles className="size-3.5" />
{t('skills.title')}
</Badge>
</div>
{skill?.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{skill.description}
</p>
)}
</div>
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key={id}
initSkillName={id}
layout="split"
sideFooter={editActions}
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
onSkillUpdated={handleSkillUpdated}
/>
</div>
</div>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="max-h-[min(420px,80vh)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<div className="py-4">{t('skills.deleteConfirmation')}</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,277 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { BookOpen, FileArchive, Loader2, PackageOpen } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { httpClient } from '@/app/infra/http/HttpClient';
import type { Skill } from '@/app/infra/entities/api';
import { cn } from '@/lib/utils';
interface PreviewSkill extends Skill {
source_path?: string;
}
interface SkillZipPreviewPanelProps {
file: File;
onImported: (skillNames: string[]) => void;
onCancel?: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function previewPath(skill: PreviewSkill): string {
return skill.source_path ?? '';
}
function displayPreviewPath(skill: PreviewSkill): string {
return previewPath(skill) || skill.name;
}
function truncateInstructions(instructions?: string): string {
if (!instructions) return '';
const trimmed = instructions.trim();
if (trimmed.length <= 900) return trimmed;
return trimmed.slice(0, 900).trimEnd() + '\n...';
}
export default function SkillZipPreviewPanel({
file,
onImported,
onCancel,
}: SkillZipPreviewPanelProps) {
const { t } = useTranslation();
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
const [activePath, setActivePath] = useState('');
const [previewing, setPreviewing] = useState(false);
const [installing, setInstalling] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const lastPreviewSignatureRef = useRef('');
const previewFileSignature = `${file.name}:${file.size}:${file.lastModified}`;
const activeSkill = useMemo(
() =>
previewSkills.find((skill) => previewPath(skill) === activePath) ||
previewSkills[0] ||
null,
[activePath, previewSkills],
);
const loadPreview = useCallback(async () => {
setPreviewing(true);
setPreviewSkills([]);
setSelectedPaths([]);
setActivePath('');
setErrorMessage(null);
try {
const resp = await httpClient.previewSkillInstallFromUpload(file);
const skills = (resp.skills || []) as PreviewSkill[];
setPreviewSkills(skills);
const paths = skills.map(previewPath);
setSelectedPaths(paths);
setActivePath(paths[0] || '');
if (skills.length === 0) {
setErrorMessage(t('skills.noSkillMdInDirectory'));
} else {
setErrorMessage(null);
}
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('skills.previewLoadError'));
} finally {
setPreviewing(false);
}
}, [file, t]);
useEffect(() => {
if (lastPreviewSignatureRef.current === previewFileSignature) return;
lastPreviewSignatureRef.current = previewFileSignature;
void loadPreview();
}, [loadPreview, previewFileSignature]);
function toggleSelection(path: string) {
setSelectedPaths((current) => {
if (current.includes(path)) {
const next = current.filter((item) => item !== path);
if (activePath === path) {
setActivePath(next[0] || path);
}
return next;
}
setActivePath(path);
return [...current, path];
});
}
async function handleInstall() {
if (selectedPaths.length === 0) return;
setInstalling(true);
setErrorMessage(null);
try {
const resp = await httpClient.installSkillFromUpload(file, selectedPaths);
toast.success(t('skills.installSuccess'));
onImported(resp.skills.map((skill) => skill.name));
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: String(error);
setErrorMessage(message || t('skills.installError'));
} finally {
setInstalling(false);
}
}
const activeInstructions = truncateInstructions(activeSkill?.instructions);
return (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md bg-muted/40 px-3 py-3">
<div className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
{previewing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<FileArchive className="size-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">
{previewing ? t('skills.loading') : t('skills.preview')}
</div>
<div className="mt-1 break-all text-xs text-muted-foreground">
{file.name} · {formatFileSize(file.size)}
</div>
</div>
</div>
{previewSkills.length > 0 && (
<div
className={cn(
'grid gap-4',
previewSkills.length > 1 && 'md:grid-cols-[240px_minmax(0,1fr)]',
)}
>
{previewSkills.length > 1 && (
<div className="space-y-2">
{previewSkills.map((skill) => {
const path = previewPath(skill);
const displayPath = displayPreviewPath(skill);
const selected = selectedPaths.includes(path);
const active = activePath === path;
return (
<button
key={`${path}:${skill.name}`}
type="button"
className={cn(
'flex w-full items-start gap-2 rounded-md px-3 py-2 text-left transition-colors',
active ? 'bg-accent' : 'bg-muted/30 hover:bg-accent/70',
)}
onClick={() => setActivePath(path)}
>
<Checkbox
checked={selected}
onCheckedChange={() => toggleSelection(path)}
onClick={(event) => event.stopPropagation()}
className="mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{skill.display_name || skill.name}
</span>
{path && (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{displayPath}
</span>
)}
</span>
</button>
);
})}
</div>
)}
{activeSkill && (
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<BookOpen className="size-4 text-muted-foreground" />
<h3 className="min-w-0 truncate text-base font-semibold">
{activeSkill.display_name || activeSkill.name}
</h3>
</div>
{activeSkill.description && (
<p className="text-sm leading-6 text-muted-foreground">
{activeSkill.description}
</p>
)}
{activeInstructions && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t('skills.previewInstructions')}
</div>
<div className="max-h-56 overflow-y-auto rounded-md bg-muted/40 p-3 font-mono text-xs leading-5 whitespace-pre-wrap">
{activeInstructions}
</div>
</div>
)}
</div>
)}
</div>
)}
{errorMessage && (
<div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="flex justify-end gap-2">
{onCancel && (
<Button variant="outline" onClick={onCancel} disabled={installing}>
{t('common.cancel')}
</Button>
)}
<Button
type="button"
onClick={handleInstall}
disabled={
previewing ||
installing ||
previewSkills.length === 0 ||
selectedPaths.length === 0
}
>
{installing ? (
<>
<Loader2 className="size-4 animate-spin" />
{t('skills.installing')}
</>
) : (
<>
<PackageOpen className="size-4" />
{t('skills.confirmInstall')}
</>
)}
</Button>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
import { useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
export default function SkillsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
const actionParam = searchParams.get('action');
const { refreshSkills } = useSidebarData();
const isCreateView = actionParam === 'create';
const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
useEffect(() => {
if (!detailId && !isCreateView) {
navigate('/home/add-extension', { replace: true });
}
}, [detailId, isCreateView, navigate]);
if (detailId) {
return <SkillDetailContent id={detailId} />;
}
function handleCreatedSkill(skillName: string) {
void refreshSkills();
navigate(`/home/skills?id=${encodeURIComponent(skillName)}`, {
replace: true,
});
}
function handleCancel() {
navigate('/home/add-extension');
}
if (!isCreateView) {
return null;
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between pb-4 shrink-0">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 className="text-xl font-semibold">{t('skills.createSkill')}</h1>
<p className="text-sm text-muted-foreground">
{t('skills.createSkillDescription')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" form="skill-form" disabled={!boxAvailable}>
{t('common.save')}
</Button>
</div>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
initSkillName={undefined}
layout="split"
onNewSkillCreated={handleCreatedSkill}
onSkillUpdated={() => {}}
/>
</div>
</div>
);
}
@@ -1,27 +0,0 @@
import styles from './createCartComponent.module.css';
export default function CreateCardComponent({
height,
plusSize,
onClick,
width = '100%',
}: {
height: string;
plusSize: string;
onClick: () => void;
width?: string;
}) {
return (
<div
className={`${styles.cardContainer} ${styles.createCardContainer} `}
style={{
width: `${width}`,
height: `${height}`,
fontSize: `${plusSize}px`,
}}
onClick={onClick}
>
+
</div>
);
}
+167
View File
@@ -30,6 +30,8 @@ export interface Requester {
spec: {
config: IDynamicFormItemSchema[];
provider_category: string;
support_type?: string[];
alias?: string;
};
}
@@ -96,6 +98,7 @@ export interface LLMModel {
provider_uuid: string;
provider?: ModelProvider;
abilities?: string[];
context_length?: number | null;
extra_args?: object;
}
@@ -280,6 +283,15 @@ export interface ApiRespPlugins {
plugins: Plugin[];
}
export type ExtensionItem =
| { type: 'plugin'; plugin: Plugin }
| { type: 'mcp'; server: MCPServer }
| { type: 'skill'; skill: Skill };
export interface ApiRespExtensions {
extensions: ExtensionItem[];
}
export interface ApiRespPlugin {
plugin: Plugin;
}
@@ -316,6 +328,10 @@ export interface SystemLimitation {
max_bots: number;
max_pipelines: number;
max_extensions: number;
/** When non-empty, every pipeline is forced to this Box sandbox-scope
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
force_box_session_id_template?: string;
}
export interface WizardProgress {
@@ -335,6 +351,10 @@ export interface ApiRespSystemInfo {
allow_modify_login_info: boolean;
disable_models_service: boolean;
limitation: SystemLimitation;
/** Public outbound IPs of the deployment (``system.outbound_ips`` in
* config.yaml). Shown on adapter config forms whose platform requires
* trusted-IP / IP-whitelist settings. Empty = not configured. */
outbound_ips: string[];
wizard_status: string; // 'none' | 'skipped' | 'completed'
wizard_progress: WizardProgress | null;
}
@@ -351,6 +371,39 @@ export interface ApiRespPluginSystemStatus {
plugin_connector_error: string;
}
export interface ApiRespBoxStatus {
available: boolean;
/** UI hint: hide the Box runtime status surface for this deployment. */
hidden?: boolean;
/** Whether ``box.enabled`` is true in config. When false, the sandbox
* is deliberately disabled distinct from "configured but failed". */
enabled?: boolean;
profile: string;
recent_error_count: number;
connector_error?: string;
backend?: {
name: string;
available: boolean;
};
active_sessions?: number;
managed_processes?: number;
session_ttl_sec?: number;
}
export interface BoxSessionInfo {
session_id: string;
backend_name: string;
image: string;
network: string;
host_path: string | null;
host_path_mode: string;
mount_path: string;
cpus: number;
memory_mb: number;
created_at: string;
last_used_at: string;
}
export interface ApiRespAsyncTasks {
tasks: AsyncTask[];
}
@@ -480,6 +533,15 @@ export interface MCPServerExtraArgsHttp {
timeout: number;
}
// "remote" mode: the user only supplies a URL; the backend auto-detects the
// transport (Streamable HTTP first, falling back to legacy SSE). headers /
// timeout are optional advanced settings.
export interface MCPServerExtraArgsRemote {
url: string;
headers?: Record<string, string>;
timeout?: number;
}
export enum MCPSessionStatus {
CONNECTING = 'connecting',
CONNECTED = 'connected',
@@ -489,8 +551,23 @@ export enum MCPSessionStatus {
export interface MCPServerRuntimeInfo {
status: MCPSessionStatus;
error_message?: string;
/** Stage at which the session failed. Frontends key off this to render
* a localized actionable message instead of the raw ``error_message``.
* Notable values: ``box_unavailable`` (stdio MCP refused because Box is
* disabled / unreachable). See ``MCPSessionErrorPhase`` (backend). */
error_phase?: string;
retry_count?: number;
tool_count: number;
tools: MCPTool[];
/** Optional ``box_session_id`` / ``box_enabled`` set when this stdio
* server runs inside Box. Absent when Box is unavailable. */
box_session_id?: string;
box_enabled?: boolean;
resource_count: number;
resources: MCPResource[];
resource_template_count?: number;
resource_templates?: MCPResourceTemplate[];
resource_capabilities?: Record<string, unknown>;
}
export type MCPServer =
@@ -501,6 +578,7 @@ export type MCPServer =
enable: boolean;
extra_args: MCPServerExtraArgsSSE;
runtime_info?: MCPServerRuntimeInfo;
readme?: string;
created_at?: string;
updated_at?: string;
}
@@ -511,6 +589,18 @@ export type MCPServer =
enable: boolean;
extra_args: MCPServerExtraArgsHttp;
runtime_info?: MCPServerRuntimeInfo;
readme?: string;
created_at?: string;
updated_at?: string;
}
| {
uuid?: string;
name: string;
mode: 'remote';
enable: boolean;
extra_args: MCPServerExtraArgsRemote;
runtime_info?: MCPServerRuntimeInfo;
readme?: string;
created_at?: string;
updated_at?: string;
}
@@ -521,6 +611,7 @@ export type MCPServer =
enable: boolean;
extra_args: MCPServerExtraArgsStdio;
runtime_info?: MCPServerRuntimeInfo;
readme?: string;
created_at?: string;
updated_at?: string;
};
@@ -531,11 +622,67 @@ export interface MCPTool {
parameters?: object;
}
export interface MCPResource {
uri: string;
name: string;
title?: string;
description: string;
mime_type: string;
size?: number;
icons?: object[];
annotations?: Record<string, unknown>;
_meta?: Record<string, unknown>;
}
export interface MCPResourceTemplate {
uri_template: string;
name: string;
title?: string;
description: string;
mime_type: string;
icons?: object[];
annotations?: Record<string, unknown>;
_meta?: Record<string, unknown>;
}
export interface MCPResourceContent {
uri: string;
mime_type: string;
type: 'text' | 'blob';
text?: string;
blob?: string | null;
bytes?: number;
truncated?: boolean;
binary_omitted?: boolean;
_meta?: Record<string, unknown>;
}
export interface ApiRespMCPResources {
resources: MCPResource[];
resource_templates?: MCPResourceTemplate[];
resource_capabilities?: Record<string, unknown>;
}
export interface ApiRespMCPResourceContents {
contents: MCPResourceContent[];
server_name?: string;
server_uuid?: string;
uri?: string;
source?: string;
bytes?: number;
truncated?: boolean;
cache_hit?: boolean;
warnings?: string[];
}
export interface PluginTool {
name: string;
description: string;
human_desc: string;
parameters: object;
source?: 'builtin' | 'plugin' | 'mcp' | 'skill';
source_name?: string;
source_id?: string;
}
export interface ApiRespTools {
@@ -545,3 +692,23 @@ export interface ApiRespTools {
export interface ApiRespToolDetail {
tool: PluginTool;
}
// Skills
export interface Skill {
name: string;
display_name?: string;
description: string;
instructions?: string;
package_root?: string;
is_builtin?: boolean;
created_at?: string;
updated_at?: string;
}
export interface ApiRespSkills {
skills: Skill[];
}
export interface ApiRespSkill {
skill: Skill;
}
+1 -1
View File
@@ -21,7 +21,7 @@ export interface ComponentManifest {
version?: string;
author?: string;
};
spec: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
spec: Record<string, any>;
}
export interface CustomApiError {
+25 -1
View File
@@ -1,9 +1,14 @@
import { I18nObject } from '@/app/infra/entities/common';
/** Namespace prefix shared by ``show_if.field`` references and display-only
* form item names whose value is resolved from the caller-supplied
* ``DynamicFormComponent.systemContext``. */
export const SYSTEM_FIELD_PREFIX = '__system.';
export interface IShowIfCondition {
field: string;
operator: 'eq' | 'neq' | 'in';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
}
@@ -11,12 +16,29 @@ export interface IDynamicFormItemSchema {
id: string;
default: string | number | boolean | Array<unknown>;
label: I18nObject;
/** Form value key. Names prefixed with ``__system.`` denote display-only
* fields whose value is resolved from
* ``DynamicFormComponent.systemContext`` (e.g. ``__system.outbound_ips``
* ``systemContext.outbound_ips``) same namespace as ``show_if``.
* Such fields are rendered read-only with copy buttons, excluded from
* form state/validation/emission, and hidden when the value is empty. */
name: string;
required: boolean;
type: DynamicFormItemType;
description?: I18nObject;
options?: IDynamicFormItemOption[];
/** When the condition matches, the field is rendered. Same evaluator as
* ``disable_if`` supports the ``__system.*`` namespace via
* ``DynamicFormComponent.systemContext``. */
show_if?: IShowIfCondition;
/** When the condition matches, the field is rendered as read-only/disabled
* but stays visible. Use this when the operator needs to see that the
* field exists but can't be edited under the current runtime state (e.g.
* a sandbox-bound field when Box is disabled). Pair with
* ``disabled_tooltip`` to explain why. */
disable_if?: IShowIfCondition;
/** Tooltip shown next to the field label when ``disable_if`` is active. */
disabled_tooltip?: I18nObject;
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
scopes?: string[];
@@ -49,6 +71,8 @@ export enum DynamicFormItemType {
PLUGIN_SELECTOR = 'plugin-selector',
BOT_SELECTOR = 'bot-selector',
TOOLS_SELECTOR = 'tools-selector',
RICH_TOOLS_SELECTOR = 'rich-tools-selector',
RESOURCES_SELECTOR = 'resources-selector',
WEBHOOK_URL = 'webhook-url',
EMBED_CODE = 'embed-code',
QR_CODE_LOGIN = 'qr-code-login',
@@ -64,6 +64,8 @@ export interface File extends MessageComponent {
name?: string;
size?: number;
url?: string;
path?: string;
base64?: string;
}
// Unknown component
+11 -1
View File
@@ -10,7 +10,7 @@ export interface Plugin {
debug: boolean;
enabled: boolean;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
install_info: Record<string, any>;
components: PluginComponent[];
}
@@ -21,6 +21,13 @@ export interface PluginComponent {
};
}
// A single log line captured from a running plugin's stderr.
export interface PluginLogEntry {
ts: number;
level: string;
text: string;
}
// marketplace plugin v4
export enum PluginV4Status {
Any = 'any',
@@ -31,6 +38,8 @@ export enum PluginV4Status {
export interface PluginV4 {
id: number;
plugin_id: string;
mcp_id?: string;
skill_id?: string;
author: string;
name: string;
label: I18nObject;
@@ -42,6 +51,7 @@ export interface PluginV4 {
latest_version: string;
components: Record<string, number>;
status: PluginV4Status;
type?: 'plugin' | 'mcp' | 'skill';
created_at: string;
updated_at: string;
}
+64
View File
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from 'react';
import type { ApiRespBoxStatus } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
/**
* Shared hook for Box runtime status used by every UI surface that needs
* to gate behaviour on whether the sandbox is available. Returns:
*
* - status: full payload (or null while loading / on error)
* - available: convenience flag (status?.available === true)
* - disabled: true iff Box is explicitly disabled by config
* (status.enabled === false), distinguishing it from
* "configured but currently failed"
* - hint: a single i18n-key choice for the banner message
* 'boxDisabled' / 'boxUnavailable' / null
* - refresh: imperative re-fetch
*
* Polls every ``refreshMs`` (default 30s) so a flapping runtime is picked
* up without a page reload.
*/
export function useBoxStatus(refreshMs = 30_000) {
const [status, setStatus] = useState<ApiRespBoxStatus | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const data = await httpClient.getBoxStatus();
setStatus(data);
} catch {
// Keep last-known status; the dashboard polls separately so a
// transient failure here should not blank the UI for sandbox
// consumers.
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const id = setInterval(() => void refresh(), refreshMs);
return () => clearInterval(id);
}, [refresh, refreshMs]);
const available = status?.available === true;
const disabled = status?.available === false && status?.enabled === false;
const hint: 'boxDisabled' | 'boxUnavailable' | null = available
? null
: disabled
? 'boxDisabled'
: status
? 'boxUnavailable'
: null;
// Specific reason from the backend (e.g.
// ``Configured sandbox backend "nsjail" is unavailable`` or
// ``docker daemon not running``). Surface this in the failed-state
// banner so the user sees WHY instead of a generic "unavailable".
// For the disabled-by-config case the boxDisabled i18n string already
// carries the reason, so we suppress the duplicate.
const reason =
hint === 'boxUnavailable' ? status?.connector_error?.trim() || null : null;
return { status, loading, available, disabled, hint, reason, refresh };
}
+421 -9
View File
@@ -15,6 +15,7 @@ import {
ApiRespPlugins,
ApiRespPlugin,
ApiRespPluginConfig,
ApiRespExtensions,
AsyncTaskCreatedResp,
ApiRespSystemInfo,
ApiRespAsyncTasks,
@@ -35,8 +36,12 @@ import {
ApiRespProviderRerankModel,
RerankModel,
ApiRespPluginSystemStatus,
ApiRespBoxStatus,
BoxSessionInfo,
ApiRespMCPServers,
ApiRespMCPServer,
ApiRespMCPResources,
ApiRespMCPResourceContents,
MCPServer,
ApiRespModelProviders,
ApiRespModelProvider,
@@ -47,8 +52,13 @@ import {
RagMigrationStatusResp,
ApiRespTools,
ApiRespToolDetail,
Skill,
ApiRespSkills,
ApiRespSkill,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import type { PluginLogEntry } from '@/app/infra/entities/plugin';
import type { I18nObject } from '@/app/infra/entities/common';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -260,10 +270,23 @@ export class BackendClient extends BaseHttpClient {
public getPipelineExtensions(uuid: string): Promise<{
enable_all_plugins: boolean;
enable_all_mcp_servers: boolean;
enable_all_skills: boolean;
mcp_resource_agent_read_enabled: boolean;
bound_plugins: Array<{ author: string; name: string }>;
available_plugins: Plugin[];
bound_mcp_servers: string[];
available_mcp_servers: MCPServer[];
bound_mcp_resources: Array<{
server_uuid?: string;
server_name?: string;
uri: string;
mode?: string;
enabled?: boolean;
max_bytes?: number;
max_tokens?: number;
}>;
bound_skills: string[];
available_skills: Skill[];
}> {
return this.get(`/api/v1/pipelines/${uuid}/extensions`);
}
@@ -274,13 +297,34 @@ export class BackendClient extends BaseHttpClient {
bound_mcp_servers: string[],
enable_all_plugins: boolean = true,
enable_all_mcp_servers: boolean = true,
bound_skills: string[] = [],
enable_all_skills: boolean = true,
bound_mcp_resources?: Array<{
server_uuid?: string;
server_name?: string;
uri: string;
mode?: string;
enabled?: boolean;
max_bytes?: number;
max_tokens?: number;
}>,
mcp_resource_agent_read_enabled?: boolean,
): Promise<object> {
return this.put(`/api/v1/pipelines/${uuid}/extensions`, {
const payload: Record<string, unknown> = {
bound_plugins,
bound_mcp_servers,
enable_all_plugins,
enable_all_mcp_servers,
});
bound_skills,
enable_all_skills,
};
if (bound_mcp_resources !== undefined) {
payload.bound_mcp_resources = bound_mcp_resources;
}
if (mcp_resource_agent_read_enabled !== undefined) {
payload.mcp_resource_agent_read_enabled = mcp_resource_agent_read_enabled;
}
return this.put(`/api/v1/pipelines/${uuid}/extensions`, payload);
}
// ============ WebSocket Chat API ============
@@ -379,6 +423,27 @@ export class BackendClient extends BaseHttpClient {
return this.delete(`/api/v1/platform/bots/${uuid}`);
}
public getBotAdmins(botId: string): Promise<{
admins: Array<{ id: number; launcher_type: string; launcher_id: string }>;
}> {
return this.get(`/api/v1/platform/bots/${botId}/admins`);
}
public addBotAdmin(
botId: string,
launcher_type: string,
launcher_id: string,
): Promise<{ id: number }> {
return this.post(`/api/v1/platform/bots/${botId}/admins`, {
launcher_type,
launcher_id,
});
}
public deleteBotAdmin(botId: string, adminId: number): Promise<object> {
return this.delete(`/api/v1/platform/bots/${botId}/admins/${adminId}`);
}
public getBotLogs(
botId: string,
request: GetBotLogsRequest,
@@ -531,6 +596,11 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/knowledge/parsers${params}`);
}
// ============ Extensions API ============
public getExtensions(): Promise<ApiRespExtensions> {
return this.get('/api/v1/extensions');
}
// ============ Plugins API ============
public getPlugins(): Promise<ApiRespPlugins> {
return this.get('/api/v1/plugins');
@@ -585,6 +655,37 @@ export class BackendClient extends BaseHttpClient {
);
}
public getPluginLogs(
author: string,
name: string,
limit: number = 200,
level?: string,
): Promise<{ logs: PluginLogEntry[] }> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (level) {
params.set('level', level);
}
return this.get(
`/api/v1/plugins/${author}/${name}/logs?${params.toString()}`,
);
}
public getMcpServerLogs(
serverName: string,
limit: number = 200,
level?: string,
): Promise<{ logs: PluginLogEntry[] }> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (level) {
params.set('level', level);
}
return this.get(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/logs?${params.toString()}`,
);
}
public getPluginAssetURL(
author: string,
name: string,
@@ -653,9 +754,12 @@ export class BackendClient extends BaseHttpClient {
published_at: string;
prerelease: boolean;
draft: boolean;
source_type?: 'release' | 'tag' | 'branch';
archive_url?: string;
}>;
owner: string;
repo: string;
source_subdir?: string;
}> {
return this.post('/api/v1/plugins/github/releases', { repo_url: repoUrl });
}
@@ -664,6 +768,9 @@ export class BackendClient extends BaseHttpClient {
owner: string,
repo: string,
releaseId: number,
releaseTag?: string,
sourceType?: 'release' | 'tag' | 'branch',
archiveUrl?: string,
): Promise<{
assets: Array<{
id: number;
@@ -677,6 +784,9 @@ export class BackendClient extends BaseHttpClient {
owner,
repo,
release_id: releaseId,
release_tag: releaseTag,
source_type: sourceType,
archive_url: archiveUrl,
});
}
@@ -686,6 +796,83 @@ export class BackendClient extends BaseHttpClient {
return this.postFile('/api/v1/plugins/install/local', formData);
}
public previewPluginInstallFromLocal(file: File): Promise<{
filename: string;
size: number;
manifest: Record<string, unknown>;
metadata: {
author?: string;
name?: string;
version?: string;
label?: I18nObject;
description?: I18nObject;
repository?: string;
};
component_types: string[];
component_counts: Record<string, number>;
requirements: string[];
file_count: number;
}> {
const formData = new FormData();
formData.append('file', file);
return this.postFile('/api/v1/plugins/install/local/preview', formData);
}
// ============ Skill Install API ============
public installSkillFromGithub(
assetUrl: string,
owner: string,
repo: string,
releaseTag: string,
sourcePaths?: string[],
sourceSubdir?: string,
): Promise<ApiRespSkills> {
return this.post('/api/v1/skills/install/github', {
asset_url: assetUrl,
owner,
repo,
release_tag: releaseTag,
source_paths: sourcePaths,
source_subdir: sourceSubdir,
});
}
public previewSkillInstallFromGithub(
assetUrl: string,
owner: string,
repo: string,
releaseTag: string,
sourceSubdir?: string,
): Promise<{ skills: Skill[] }> {
return this.post('/api/v1/skills/install/github/preview', {
asset_url: assetUrl,
owner,
repo,
release_tag: releaseTag,
source_subdir: sourceSubdir,
});
}
public previewSkillInstallFromUpload(
file: File,
): Promise<{ skills: Skill[] }> {
const formData = new FormData();
formData.append('file', file);
return this.postFile('/api/v1/skills/install/upload/preview', formData);
}
public installSkillFromUpload(
file: File,
sourcePaths?: string[],
): Promise<ApiRespSkills> {
const formData = new FormData();
formData.append('file', file);
for (const sourcePath of sourcePaths || []) {
formData.append('source_paths', sourcePath);
}
return this.postFile('/api/v1/skills/install/upload', formData);
}
public installPluginFromMarketplace(
author: string,
name: string,
@@ -722,8 +909,11 @@ export class BackendClient extends BaseHttpClient {
// ========== Tools ==========
public getTools(): Promise<ApiRespTools> {
return this.get('/api/v1/tools');
public getTools(pipelineId?: string): Promise<ApiRespTools> {
return this.get(
'/api/v1/tools',
pipelineId ? { pipeline_uuid: pipelineId } : undefined,
);
}
public getToolDetail(toolName: string): Promise<ApiRespToolDetail> {
@@ -731,7 +921,7 @@ export class BackendClient extends BaseHttpClient {
}
public getMCPServer(serverName: string): Promise<ApiRespMCPServer> {
return this.get(`/api/v1/mcp/servers/${serverName}`);
return this.get(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`);
}
public createMCPServer(server: MCPServer): Promise<AsyncTaskCreatedResp> {
@@ -742,18 +932,21 @@ export class BackendClient extends BaseHttpClient {
serverName: string,
server: Partial<MCPServer>,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, server);
return this.put(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`,
server,
);
}
public deleteMCPServer(serverName: string): Promise<AsyncTaskCreatedResp> {
return this.delete(`/api/v1/mcp/servers/${serverName}`);
return this.delete(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`);
}
public toggleMCPServer(
serverName: string,
target_enabled: boolean,
): Promise<AsyncTaskCreatedResp> {
return this.put(`/api/v1/mcp/servers/${serverName}`, {
return this.put(`/api/v1/mcp/servers/${encodeURIComponent(serverName)}`, {
enable: target_enabled,
});
}
@@ -762,7 +955,10 @@ export class BackendClient extends BaseHttpClient {
serverName: string,
serverData: object,
): Promise<AsyncTaskCreatedResp> {
return this.post(`/api/v1/mcp/servers/${serverName}/test`, serverData);
return this.post(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/test`,
serverData,
);
}
public installMCPServerFromGithub(
@@ -777,6 +973,28 @@ export class BackendClient extends BaseHttpClient {
return this.post('/api/v1/mcp/servers', { source });
}
public getMCPServerResources(
serverName: string,
): Promise<ApiRespMCPResources> {
return this.get(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/resources`,
);
}
public readMCPServerResource(
serverName: string,
uri: string,
maxBytes?: number,
): Promise<ApiRespMCPResourceContents> {
return this.post(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/resources/read`,
{
uri,
max_bytes: maxBytes,
},
);
}
// ============ System API ============
public getSystemInfo(): Promise<ApiRespSystemInfo> {
return this.get('/api/v1/system/info');
@@ -839,6 +1057,14 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/plugins/debug-info');
}
public getBoxStatus(): Promise<ApiRespBoxStatus> {
return this.get('/api/v1/box/status');
}
public getBoxSessions(): Promise<BoxSessionInfo[]> {
return this.get('/api/v1/box/sessions');
}
// ============ User API ============
public checkIfInited(): Promise<{ initialized: boolean }> {
return this.get('/api/v1/user/init');
@@ -988,8 +1214,10 @@ export class BackendClient extends BaseHttpClient {
level: string;
platform?: string;
user_id?: string;
user_name?: string;
runner_name?: string;
variables?: string;
role?: string;
}>;
llmCalls: Array<{
id: string;
@@ -1005,9 +1233,27 @@ export class BackendClient extends BaseHttpClient {
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
error_message?: string;
message_id?: string;
}>;
toolCalls: Array<{
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
message_id?: string;
arguments?: string;
result?: string;
error_message?: string;
}>;
embeddingCalls: Array<{
id: string;
timestamp: string;
@@ -1052,6 +1298,7 @@ export class BackendClient extends BaseHttpClient {
totalCount: {
messages: number;
llmCalls: number;
toolCalls?: number;
embeddingCalls: number;
sessions: number;
errors: number;
@@ -1105,6 +1352,68 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/monitoring/overview?${queryParams.toString()}`);
}
public getTokenStatistics(params: {
botId?: string[];
pipelineId?: string[];
startTime?: string;
endTime?: string;
bucket?: 'hour' | 'day';
}): Promise<{
summary: {
total_calls: number;
success_calls: number;
error_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_cost: number;
avg_tokens_per_call: number;
avg_duration_ms: number;
avg_tokens_per_second: number;
zero_token_success_calls: number;
};
by_model: Array<{
model_name: string;
calls: number;
error_calls: number;
input_tokens: number;
output_tokens: number;
total_tokens: number;
cost: number;
avg_tokens_per_call: number;
avg_duration_ms: number;
}>;
timeseries: Array<{
bucket: string;
input_tokens: number;
output_tokens: number;
total_tokens: number;
calls: number;
}>;
bucket: string;
}> {
const queryParams = new URLSearchParams();
if (params.botId) {
params.botId.forEach((id) => queryParams.append('botId', id));
}
if (params.pipelineId) {
params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
}
if (params.startTime) {
queryParams.append('startTime', params.startTime);
}
if (params.endTime) {
queryParams.append('endTime', params.endTime);
}
if (params.bucket) {
queryParams.append('bucket', params.bucket);
}
return this.get(
`/api/v1/monitoring/token-statistics?${queryParams.toString()}`,
);
}
// ============ Survey API ============
public getSurveyPending(): Promise<{
survey: {
@@ -1133,6 +1442,109 @@ export class BackendClient extends BaseHttpClient {
public dismissSurvey(surveyId: string): Promise<object> {
return this.post('/api/v1/survey/dismiss', { survey_id: surveyId });
}
public submitFeedback(data: {
content: string;
attachments?: Array<{
name: string;
mime_type: string;
data_url: string;
}>;
}): Promise<object> {
return this.post('/api/v1/survey/feedback', data);
}
// ============ Skills API ============
public getSkills(): Promise<ApiRespSkills> {
return this.get('/api/v1/skills');
}
public getSkill(name: string): Promise<ApiRespSkill> {
return this.get(`/api/v1/skills/${name}`);
}
public createSkill(
skill: Omit<Skill, 'name'> & { name: string },
): Promise<ApiRespSkill> {
return this.post('/api/v1/skills', skill);
}
public updateSkill(
name: string,
skill: Partial<Skill>,
): Promise<ApiRespSkill> {
return this.put(`/api/v1/skills/${name}`, skill);
}
public deleteSkill(name: string): Promise<object> {
return this.delete(`/api/v1/skills/${name}`);
}
public previewSkill(name: string): Promise<{ instructions: string }> {
return this.get(`/api/v1/skills/${name}/preview`);
}
public getSkillIndex(pipelineUuid?: string): Promise<{ index: string }> {
const params = pipelineUuid ? { pipeline_uuid: pipelineUuid } : {};
return this.get('/api/v1/skills/index', params);
}
public scanSkillDirectory(path: string): Promise<{
package_root: string;
name: string;
display_name?: string;
description: string;
instructions: string;
}> {
return this.get('/api/v1/skills/scan', { path });
}
public listSkillFiles(
skillName: string,
path: string = '.',
includeHidden: boolean = false,
): Promise<{
skill: { name: string };
base_path: string;
entries: Array<{
path: string;
name: string;
is_dir: boolean;
size: number | null;
}>;
truncated: boolean;
}> {
return this.get(`/api/v1/skills/${skillName}/files`, {
path,
include_hidden: includeHidden,
});
}
public readSkillFile(
skillName: string,
filePath: string,
): Promise<{
skill: { name: string };
path: string;
content: string;
}> {
return this.get(`/api/v1/skills/${skillName}/files/${filePath}`);
}
public writeSkillFile(
skillName: string,
filePath: string,
content: string,
): Promise<{
skill: { name: string };
path: string;
bytes_written: number;
}> {
return this.put(`/api/v1/skills/${skillName}/files/${filePath}`, {
content,
});
}
}
export interface SurveyQuestion {

Some files were not shown because too many files have changed in this diff Show More