Feat/shadcn sidebar and page views (#2084)

* feat(web): migrate sidebar to shadcn and convert entity editors to page views

* feat(web): enhance sidebar with sections, collapsible persistence, sub-item sorting/limiting, and UI polish

- Reorganize sidebar into Home and Extensions sections with collapsible groups
- Split plugins page into plugins, market, and mcp as separate routes
- Add sidebar sub-items sorted by updatedAt with max 5 visible and expand/collapse toggle
- Persist collapsible section state and sidebar open state in localStorage
- Fix page refresh stripping query params by splitting handleChildClick/selectChild
- Swap plugin detail layout (config left, readme right)
- Add fixed headers with internal scroll for all detail and list pages
- Remove entity form borders and sidebar rail
- Improve dark mode sidebar/content contrast
- Rename monitoring to Dashboard, move to first position
- Update breadcrumb to show Home or Extensions based on current route
- Add i18n translations for more/less toggle in all 4 locales

* fix(web): fix scroll behavior - constrain layout to viewport, fix fixed headers and independent scroll areas

- Change SidebarProvider wrapper from min-h-svh to h-svh overflow-hidden to constrain layout to viewport height (root cause of all scroll issues)
- Fix create mode pages (bot, pipeline, knowledge): extract title bar out of scroll container so only form content scrolls
- Fix plugin detail: add overflow-x-hidden on both config and readme panels to prevent horizontal overflow
- Add min-h-0 to all TabsContent in edit mode for cross-browser flex shrink safety
- Change nested <main> to <div> in layout to avoid invalid nested <main> tags (SidebarInset already renders as <main>)

* style(web): polish UI - dashboard i18n, sidebar create text, cursor-pointer tabs, remove cancel buttons

* feat(web): add plugin context menu to sidebar sub-items

- Add hover-reveal dropdown menu (Ellipsis icon) on plugin sidebar items
- Menu items: Update (marketplace only), View Source (marketplace/github), Delete
- Add confirmation dialog with async task polling for delete/update operations
- Extend SidebarEntityItem with installSource and installInfo fields
- Fix PipelineFormComponent optional onCancel invocation

* fix(web): prevent plugin sidebar text from overlapping menu button

Add right padding on plugin sub-items and explicit truncate on text
span so long plugin names never overlap the hover menu button.

* feat(web): show update indicator on sidebar plugin menu

- Fetch marketplace plugin versions in SidebarDataContext.refreshPlugins
- Compare with installed version using isNewerVersion to set hasUpdate
- Show red dot on menu trigger when update available (always visible)
- Show 'New' badge on Update menu item when update available
- Marketplace fetch failure is silently caught to avoid blocking sidebar

* refactor(web): remove entity list pages, back buttons, and make sidebar toggle collapse

- Remove card grid list views from bots, pipelines, knowledge pages
- Show empty state placeholder when no entity is selected
- Preserve KB migration dialog at page level
- Remove back (ArrowLeft) buttons from all detail pages (bots, pipelines, knowledge, plugins)
- Sidebar parent click for bots/pipelines/knowledge now toggles collapse instead of navigating
- Breadcrumb second level is now non-clickable (always BreadcrumbPage)
- Add selectFromSidebar i18n keys in all 4 locales

* feat(web): enhance bot session monitor with refresh functionality and improve log card UI

* refactor(web): optimize pipeline detail page with vertical config nav and debug chat polish

- Convert pipeline config tab's horizontal sub-tabs to vertical left-side navigation with icons
- Replace hardcoded colors in PipelineFormComponent and DebugDialog with theme-aware Tailwind classes
- Replace custom SVG icons with lucide-react (User, Users, ImageIcon, Send, Reply, etc.)
- Replace hardcoded Chinese strings with i18n keys (allMembers, file, voice, uploadImage, uploading)
- Modernize chat bubbles to use bg-primary/10 and bg-muted instead of hardcoded blue/gray
- Translate all Chinese comments to English in both components
- Delete unused pipelineFormStyle.module.css
- Remove max-w-2xl constraint from config tab container

* fix(web): improve dark mode contrast and relocate WebSocket status indicator

Bump dark mode --muted, --accent, --secondary from oklch(0.18) to oklch(0.24)
to fix invisible TabsList, message bubbles, and selected items against the
oklch(0.17) background. Move WebSocket connection dot from pipeline title
into the Debug Chat tab trigger so it is always visible. Replace hardcoded
Quote border colors with theme-aware border-muted-foreground/50.

* fix(web): increase dark mode contrast for muted/accent/secondary to oklch(0.27)

Previous value of oklch(0.24) was still not distinguishable enough against
the oklch(0.17) background. Bump to oklch(0.27) for a 0.10 lightness gap,
matching the contrast ratio of the default shadcn zinc dark theme.

* style(web): replace hardcoded colors with theme tokens in monitoring dashboard

Convert all monitoring page components from hardcoded gray/white colors
to theme-aware CSS variable tokens (bg-card, text-foreground,
text-muted-foreground, bg-muted, bg-background, bg-accent, border).
Semantic colors (red/green/blue/purple for status badges and error
styling) are intentionally preserved.

* feat(web): show debug indicator for debugging plugins in sidebar

Add orange Bug icon next to plugin name in sidebar sub-items when the
plugin is connected via WebSocket debug mode. Hide context menu for
debug plugins since delete/update operations are not supported.

* feat(web): show install source and debug badge on plugin detail page

Display a badge next to the plugin title indicating the install source
(GitHub blue, Local green, Marketplace purple) or debugging status
(orange with Bug icon), matching the existing plugin card convention.

* fix(web): resolve eslint errors for CI - remove unused imports and variables

* fix(web): remove stale setSubtitle call and fix prettier formatting

* Refactor code formatting and improve readability

- Updated HomeSidebar.tsx to enhance clarity in conditional assignment.
- Adjusted CSS formatting in github-markdown.css for better alignment.
- Cleaned up tsconfig.json by consolidating array formatting for consistency.

* fix(ci): use local prettier instead of mirrors-prettier to avoid version mismatch (3.1.0 vs 3.8.1)
This commit is contained in:
Junyan Chin
2026-03-27 01:51:13 +08:00
committed by GitHub
parent 3c0495fc51
commit 4a97895dea
59 changed files with 6785 additions and 4331 deletions
+220
View File
@@ -0,0 +1,220 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
export default function BotDetailContent({ id }: { id: string }) {
const isCreateMode = id === 'new';
const router = useRouter();
const { t } = useTranslation();
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
// Set breadcrumb entity name
useEffect(() => {
if (isCreateMode) {
setDetailEntityName(t('bots.createBot'));
} else {
const bot = bots.find((b) => b.id === id);
setDetailEntityName(bot?.name ?? id);
}
return () => setDetailEntityName(null);
}, [id, isCreateMode, bots, setDetailEntityName, t]);
const [activeTab, setActiveTab] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
function handleFormSubmit() {
refreshBots();
}
function handleBotDeleted() {
refreshBots();
router.push('/home/bots');
}
function handleNewBotCreated(newBotId: string) {
refreshBots();
// Navigate to the newly created bot's detail view via query param
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
}
function handleDelete() {
setShowDeleteConfirm(true);
}
function confirmDelete() {
httpClient.deleteBot(id).then(() => {
setShowDeleteConfirm(false);
handleBotDeleted();
});
}
// Create mode: simple form layout
if (isCreateMode) {
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">{t('bots.createBot')}</h1>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="mx-auto max-w-2xl space-y-6">
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
/>
<div className="flex justify-end gap-2 pb-4">
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
</div>
</div>
</div>
</div>
);
}
// Edit mode: tabbed layout with config, logs, sessions
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">{t('bots.editBot')}</h1>
</div>
<Tabs
key={id}
value={activeTab}
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>
<TabsContent
value="config"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<div className="mx-auto max-w-2xl">
<BotForm
initBotId={id}
onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
/>
<div className="flex justify-end gap-2 mt-6 pb-4">
<Button
type="button"
variant="destructive"
onClick={handleDelete}
>
{t('common.delete')}
</Button>
<Button type="submit" form="bot-form">
{t('common.save')}
</Button>
</div>
</div>
</TabsContent>
<TabsContent
value="logs"
className="flex-1 min-h-0 overflow-y-auto mt-4"
>
<BotLogListComponent botId={id} />
</TabsContent>
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent>
</Tabs>
</div>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription className="sr-only">
{t('bots.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<div className="py-4">{t('bots.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>
</>
);
}
-297
View File
@@ -1,297 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogDescription,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from '@/components/ui/sidebar';
import { Button } from '@/components/ui/button';
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { httpClient } from '@/app/infra/http/HttpClient';
interface BotDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
botId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onFormSubmit: (value: z.infer<any>) => void;
onFormCancel: () => void;
onBotDeleted: () => void;
onNewBotCreated: (botId: string) => void;
}
export default function BotDetailDialog({
open,
onOpenChange,
botId: propBotId,
onFormSubmit,
onFormCancel,
onBotDeleted,
onNewBotCreated,
}: BotDetailDialogProps) {
const { t } = useTranslation();
const [botId, setBotId] = useState<string | undefined>(propBotId);
const [activeMenu, setActiveMenu] = useState('config');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
setBotId(propBotId);
setActiveMenu('config');
}, [propBotId, open]);
const menu = [
{
key: 'config',
label: t('bots.configuration'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
</svg>
),
},
{
key: 'logs',
label: t('bots.logs'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
</svg>
),
},
{
key: 'sessions',
label: t('bots.sessionMonitor.title'),
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.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.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>
),
},
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleFormSubmit = (value: any) => {
onFormSubmit(value);
};
const handleFormCancel = () => {
onFormCancel();
};
const handleBotDeleted = () => {
httpClient.deleteBot(botId ?? '').then(() => {
onBotDeleted();
});
};
const handleNewBotCreated = (newBotId: string) => {
setBotId(newBotId);
setActiveMenu('config');
onNewBotCreated(newBotId);
};
const handleDelete = () => {
setShowDeleteConfirm(true);
};
const confirmDelete = () => {
handleBotDeleted();
setShowDeleteConfirm(false);
};
if (!botId) {
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
<main className="flex flex-1 flex-col h-[70vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>{t('bots.createBot')}</DialogTitle>
<DialogDescription className="sr-only">
{t('bots.createBot')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
/>
</div>
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button type="submit" form="bot-form">
{t('common.submit')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleFormCancel}
>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
</main>
</DialogContent>
</Dialog>
</>
);
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[70rem] max-h-[75vh] flex">
<SidebarProvider className="items-start w-full flex">
<Sidebar
collapsible="none"
className="hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{menu.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
asChild
isActive={activeMenu === item.key}
onClick={() => setActiveMenu(item.key)}
>
<a href="#">
{item.icon}
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex flex-1 flex-col h-[75vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>
{activeMenu === 'config'
? t('bots.editBot')
: activeMenu === 'logs'
? t('bots.botLogTitle')
: t('bots.sessionMonitor.title')}
</DialogTitle>
<DialogDescription className="sr-only">
{activeMenu === 'config'
? t('bots.editBot')
: activeMenu === 'logs'
? t('bots.botLogTitle')
: t('bots.sessionMonitor.title')}
</DialogDescription>
</DialogHeader>
<div
className={
activeMenu === 'sessions'
? 'flex-1 min-h-0'
: 'flex-1 overflow-y-auto px-6 pb-6'
}
>
{activeMenu === 'config' && (
<BotForm
initBotId={botId}
onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
/>
)}
{activeMenu === 'logs' && botId && (
<BotLogListComponent botId={botId} />
)}
{activeMenu === 'sessions' && botId && (
<BotSessionMonitor botId={botId} />
)}
</div>
{activeMenu === 'config' && (
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="destructive"
onClick={handleDelete}
>
{t('common.delete')}
</Button>
<Button type="submit" form="bot-form">
{t('common.save')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleFormCancel}
>
{t('common.cancel')}
</Button>
</div>
</DialogFooter>
)}
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription className="sr-only">
{t('bots.deleteConfirmation')}
</DialogDescription>
</DialogHeader>
<div className="py-4">{t('bots.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>
</>
);
}
@@ -3,7 +3,7 @@
height: 10rem;
background-color: #fff;
border-radius: 10px;
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
border: 1px solid #e4e4e7;
padding: 1.2rem;
cursor: pointer;
transition: all 0.2s ease;
@@ -11,15 +11,15 @@
:global(.dark) .cardContainer {
background-color: #1f1f22;
box-shadow: 0;
border-color: #27272a;
}
.cardContainer:hover {
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
border-color: #a1a1aa;
}
:global(.dark) .cardContainer:hover {
box-shadow: 0;
border-color: #3f3f46;
}
.iconBasicInfoContainer {
@@ -2,12 +2,22 @@
import { useState } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import styles from './botLog.module.css';
import { httpClient } from '@/app/infra/http/HttpClient';
import { PhotoProvider } from 'react-photo-view';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
warning:
'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
debug: 'bg-muted text-muted-foreground',
};
const SHORT_TEXT_LIMIT = 120;
export function BotLogCard({ botLog }: { botLog: BotLog }) {
const { t } = useTranslation();
@@ -15,207 +25,158 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
// Fallback 复制方法,用于不支持 clipboard API 的环境
function copySessionId() {
const text = botLog.message_session_id;
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
})
.catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text: string) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
} catch {
toast.error(t('common.copyFailed'));
}
document.body.removeChild(textArea);
document.body.removeChild(ta);
}
function formatTime(timestamp: number) {
const now = new Date();
const date = new Date(timestamp * 1000);
// 获取各个时间部分
const year = date.getFullYear();
const month = date.getMonth() + 1; // 月份从0开始,需要+1
const day = date.getDate();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// 判断时间范围
const isToday = now.toDateString() === date.toDateString();
const isYesterday =
new Date(now.setDate(now.getDate() - 1)).toDateString() ===
date.toDateString();
const isThisYear = now.getFullYear() === year;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = yesterday.toDateString() === date.toDateString();
const isThisYear = now.getFullYear() === date.getFullYear();
if (isToday) {
return `${hours}:${minutes}`; // 今天的消息:小时:分钟
} else if (isYesterday) {
return `${t('bots.yesterday')} ${hours}:${minutes}`; // 昨天的消息:昨天 小时:分钟
} else if (isThisYear) {
return t('bots.dateFormat', { month, day }); // 本年消息:x月x日
} else {
return t('bots.earlier'); // 更早的消息:更久之前
}
if (isToday) return `${hours}:${minutes}`;
if (isYesterday) return `${t('bots.yesterday')} ${hours}:${minutes}`;
if (isThisYear)
return t('bots.dateFormat', {
month: date.getMonth() + 1,
day: date.getDate(),
});
return t('bots.earlier');
}
function getSubChatId(str: string) {
const strArr = str.split('');
return strArr;
}
// 根据日志级别返回对应的样式类
function getLevelStyles(level: string) {
switch (level.toLowerCase()) {
case 'error':
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
case 'warning':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
case 'info':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
case 'debug':
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
}
}
// 截取文本的简短版本
function getShortText(text: string, maxLength: number = 100) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
// 判断是否需要展开按钮
const needsExpand = botLog.text.length > 100 || botLog.images.length > 0;
const needsExpand =
botLog.text.length > SHORT_TEXT_LIMIT || botLog.images.length > 0;
const levelStyle =
LEVEL_STYLES[botLog.level.toLowerCase()] ?? LEVEL_STYLES.debug;
return (
<div className={`${styles.botLogCardContainer}`}>
{/* 头部标签,时间 */}
<div className={`${styles.cardTitleContainer}`}>
<div className={`flex flex-row gap-2 items-center`}>
<div
className={`px-2 py-1 rounded text-xs font-medium uppercase ${getLevelStyles(
botLog.level,
)}`}
<div className="rounded-lg border bg-card px-3.5 py-3 transition-colors hover:border-border/80">
{/* Header: level badge, session id, expand toggle, timestamp */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
{/* Level badge */}
<span
className={cn(
'inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase leading-none',
levelStyle,
)}
>
{botLog.level}
</div>
</span>
{/* Session ID */}
{botLog.message_session_id && (
<div
className={`${styles.tag} ${styles.chatTag} relative`}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
// 兼容性更好的复制方法
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(botLog.message_session_id)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
})
.catch(() => {
// fallback
fallbackCopy(botLog.message_session_id);
});
} else {
fallbackCopy(botLog.message_session_id);
}
copySessionId();
}}
title={t('common.clickToCopy')}
className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[11px] font-mono text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors truncate max-w-48 cursor-pointer"
>
{copied ? (
<Check className="w-4 h-4 text-green-600" />
<Check className="size-3 shrink-0 text-green-600" />
) : (
<svg
className="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1664"
width="16"
height="16"
fill="currentColor"
>
<path
d="M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z"
p-id="1665"
fill="currentColor"
></path>
<path
d="M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z"
p-id="1666"
fill="currentColor"
></path>
<path
d="M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z"
p-id="1667"
fill="currentColor"
></path>
</svg>
<Copy className="size-3 shrink-0" />
)}
<span className={`${styles.chatId}`}>
{getSubChatId(botLog.message_session_id)}
</span>
</div>
<span className="truncate">{botLog.message_session_id}</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{needsExpand && (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
className="flex items-center gap-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
{expanded ? (
<>
<ChevronDown className="w-3 h-3" />
<ChevronDown className="size-3" />
{t('bots.collapse')}
</>
) : (
<>
<ChevronRight className="w-3 h-3" />
<ChevronRight className="size-3" />
{t('bots.viewDetails')}
</>
)}
</button>
)}
<div className={`${styles.timestamp}`}>
<span className="text-[11px] text-muted-foreground tabular-nums">
{formatTime(botLog.timestamp)}
</div>
</span>
</div>
</div>
{/* 日志内容 - 简化显示 */}
<div className={`${styles.cardText}`}>
{expanded ? botLog.text : getShortText(botLog.text)}
{/* Log text */}
<div className="mt-2 text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
{expanded
? botLog.text
: botLog.text.length > SHORT_TEXT_LIMIT
? botLog.text.slice(0, SHORT_TEXT_LIMIT) + '...'
: botLog.text}
</div>
{/* 图片 - 只在展开时显示 */}
{/* Images (expanded) */}
{expanded && botLog.images.length > 0 && (
<PhotoProvider>
<div className={`flex flex-wrap gap-2 mt-3`}>
<div className="flex flex-wrap gap-2 mt-2.5">
{botLog.images.map((item) => (
<img
key={item}
src={`${baseURL}/api/v1/files/image/${item}`}
alt=""
className="max-w-xs rounded cursor-pointer hover:opacity-90 transition-opacity"
className="max-w-xs rounded-md cursor-pointer hover:opacity-90 transition-opacity"
/>
))}
</div>
</PhotoProvider>
)}
{/* 图片数量提示 - 未展开时显示 */}
{/* Image count hint (collapsed) */}
{!expanded && botLog.images.length > 0 && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
📷 {botLog.images.length} {t('bots.imagesAttached')}
<div className="mt-1.5 text-[11px] text-muted-foreground">
{botLog.images.length} {t('bots.imagesAttached')}
</div>
)}
</div>
@@ -4,7 +4,6 @@ import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager'
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';
import styles from './botLog.module.css';
import { Switch } from '@/components/ui/switch';
import {
Popover,
@@ -50,7 +49,6 @@ export function BotLogListComponent({ botId }: { botId: string }) {
botLogListRef.current = botLogList;
}, [botLogList]);
// 根据级别过滤日志
const filteredLogs = useMemo(() => {
if (selectedLevels.length === 0) {
return botLogList;
@@ -75,18 +73,15 @@ export function BotLogListComponent({ botId }: { botId: string }) {
if (selectedLevels.length === logLevels.length) {
return t('bots.allLevels');
}
// 如果选中3个或以上,显示数量
if (selectedLevels.length >= 3) {
return `${selectedLevels.length} ${t('bots.levelsSelected')}`;
}
// 显示选中级别的标签(大写形式)
return logLevels
.filter((level) => selectedLevels.includes(level.value))
.map((level) => level.label)
.join(', ');
};
// 观测自动刷新状态
useEffect(() => {
if (autoFlush) {
manager.startListenServerPush();
@@ -99,13 +94,10 @@ export function BotLogListComponent({ botId }: { botId: string }) {
}, [autoFlush]);
function initComponent() {
// 订阅日志推送
manager.subscribeLogPush(handleBotLogPush);
// 加载第一页日志
manager.loadFirstPage().then((response) => {
setBotLogList(response.reverse());
});
// 监听滚动
listenScroll();
}
@@ -115,28 +107,19 @@ export function BotLogListComponent({ botId }: { botId: string }) {
}
function listenScroll() {
if (!listContainerRef.current) {
return;
}
const list = listContainerRef.current;
list.addEventListener('scroll', handleScroll);
if (!listContainerRef.current) return;
listContainerRef.current.addEventListener('scroll', handleScroll);
}
function removeScrollListener() {
if (!listContainerRef.current) {
return;
}
const list = listContainerRef.current;
list.removeEventListener('scroll', handleScroll);
if (!listContainerRef.current) return;
listContainerRef.current.removeEventListener('scroll', handleScroll);
}
function loadMore() {
// 加载更多日志
const list = botLogListRef.current;
const lastSeq = list[list.length - 1].seq_id;
if (lastSeq === 0) {
return;
}
if (lastSeq === 0) return;
manager.loadMore(lastSeq - 1, 10).then((response) => {
setBotLogList([...list, ...response.reverse()]);
});
@@ -165,63 +148,87 @@ export function BotLogListComponent({ botId }: { botId: string }) {
if (!isTop && !isBottom) {
setAutoFlush(false);
}
}, 300), // 防抖延迟 300ms
[botLogList], // 依赖项为空
}, 300),
[botLogList],
);
return (
<div className={`${styles.botLogListContainer}`} ref={listContainerRef}>
<div className={`${styles.listHeader}`}>
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />
<div className={'ml-4 mr-2'}>{t('bots.logLevel')}</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[180px] flex items-center justify-between"
>
<span className="text-sm truncate flex-1 text-left">
{getDisplayText()}
</span>
<ChevronDownIcon className="ml-2 h-4 w-4 flex-shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-2">
<div className="flex flex-col gap-2">
{logLevels.map((level) => (
<div key={level.value} className="flex items-center space-x-2">
<Checkbox
id={level.value}
checked={selectedLevels.includes(level.value)}
onCheckedChange={() => handleLevelToggle(level.value)}
/>
<label
htmlFor={level.value}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
<div
className="flex flex-col h-full min-h-0 overflow-y-auto"
ref={listContainerRef}
>
{/* Toolbar */}
<div className="flex items-center gap-3 pb-3 shrink-0 flex-wrap">
{/* Auto-refresh toggle */}
<div className="flex items-center gap-1.5">
<span className="text-sm text-muted-foreground">
{t('bots.enableAutoRefresh')}
</span>
<Switch
checked={autoFlush}
onCheckedChange={(v) => setAutoFlush(v)}
/>
</div>
{/* Level filter */}
<div className="flex items-center gap-1.5">
<span className="text-sm text-muted-foreground">
{t('bots.logLevel')}
</span>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-[160px] justify-between"
>
<span className="text-sm truncate">{getDisplayText()}</span>
<ChevronDownIcon className="size-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[160px] p-2">
<div className="flex flex-col gap-2">
{logLevels.map((level) => (
<div
key={level.value}
className="flex items-center space-x-2"
>
{level.label}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
<Checkbox
id={level.value}
checked={selectedLevels.includes(level.value)}
onCheckedChange={() => handleLevelToggle(level.value)}
/>
<label
htmlFor={level.value}
className="text-sm font-medium leading-none cursor-pointer"
>
{level.label}
</label>
</div>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* Link to detailed logs */}
<Button
variant="outline"
size="sm"
className="ml-4 flex items-center gap-1"
className="gap-1"
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
>
<ExternalLink className="h-4 w-4" />
<ExternalLink className="size-3.5" />
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
</Button>
</div>
{filteredLogs.map((botLog) => {
return <BotLogCard botLog={botLog} key={botLog.seq_id} />;
})}
{/* Log cards */}
<div className="flex flex-col gap-2">
{filteredLogs.map((botLog) => (
<BotLogCard botLog={botLog} key={botLog.seq_id} />
))}
</div>
</div>
);
}
@@ -1,142 +0,0 @@
.botLogListContainer {
width: 100%;
max-width: 100%;
min-height: 10rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
overflow-y: auto;
overflow-x: hidden;
box-sizing: border-box;
}
.botLogCardContainer {
width: 100%;
max-width: 100%;
background-color: #fff;
border-radius: 8px;
border: 1px solid #e2e8f0;
padding: 1rem;
margin-bottom: 0.75rem;
transition: all 0.2s ease;
overflow: hidden;
box-sizing: border-box;
}
.botLogCardContainer:hover {
border-color: #cbd5e1;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
:global(.dark) .botLogCardContainer {
background-color: #1f1f22;
border: 1px solid #2a2a2e;
}
:global(.dark) .botLogCardContainer:hover {
border-color: #3a3a3e;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.listHeader {
width: 100%;
height: 2.5rem;
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 0.5rem;
}
.tag {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.25rem;
height: auto;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background-color: #dbeafe;
color: #1e40af;
font-size: 0.75rem;
font-weight: 500;
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-transform: uppercase;
letter-spacing: 0.025em;
}
:global(.dark) .tag {
background-color: #1e3a8a;
color: #93c5fd;
}
.chatTag {
color: #4b5563;
background-color: #f3f4f6;
text-transform: none;
cursor: pointer;
transition: all 0.15s ease;
}
.chatTag:hover {
background-color: #e5e7eb;
}
:global(.dark) .chatTag {
color: #9ca3af;
background-color: #374151;
}
:global(.dark) .chatTag:hover {
background-color: #4b5563;
}
.chatId {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
'Courier New', monospace;
font-size: 0.7rem;
}
.cardTitleContainer {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.cardText {
color: #1e293b;
font-size: 0.875rem;
line-height: 1.7;
white-space: pre-wrap;
word-wrap: break-word;
word-break: break-all;
overflow-wrap: anywhere;
hyphens: auto;
max-width: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', sans-serif;
}
:global(.dark) .cardText {
color: #e2e8f0;
}
.timestamp {
color: #64748b;
font-size: 0.75rem;
white-space: nowrap;
}
:global(.dark) .timestamp {
color: #64748b;
}
@@ -1,10 +1,16 @@
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, {
useState,
useEffect,
useRef,
useCallback,
forwardRef,
useImperativeHandle,
} from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Copy, Check } from 'lucide-react';
import {
@@ -49,11 +55,18 @@ interface SessionMessage {
role?: string | null;
}
export interface BotSessionMonitorHandle {
refreshSessions: () => Promise<void>;
}
interface BotSessionMonitorProps {
botId: string;
}
export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
const BotSessionMonitor = forwardRef<
BotSessionMonitorHandle,
BotSessionMonitorProps
>(function BotSessionMonitor({ botId }, ref) {
const { t } = useTranslation();
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
@@ -97,6 +110,14 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
}
}, [botId]);
useImperativeHandle(
ref,
() => ({
refreshSessions: loadSessions,
}),
[loadSessions],
);
const loadMessages = useCallback(async (sessionId: string) => {
setLoadingMessages(true);
try {
@@ -235,7 +256,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
return (
<div
key={index}
className="mb-2 pl-2.5 border-l-2 border-gray-300 dark:border-gray-600 opacity-80"
className="mb-2 pl-2.5 border-l-2 border-muted-foreground/50 opacity-80"
>
<div className="text-sm">
{quote.origin?.map((comp, idx) =>
@@ -278,9 +299,17 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
);
};
// Backend timestamps may lack timezone indicator; treat as UTC
const parseTimestamp = (timestamp: string): Date => {
if (!timestamp) return new Date(0);
const hasTimezone =
timestamp.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(timestamp);
return new Date(hasTimezone ? timestamp : timestamp + 'Z');
};
const formatTime = (timestamp: string): string => {
if (!timestamp) return '';
const date = new Date(timestamp);
const date = parseTimestamp(timestamp);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
@@ -288,7 +317,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
const formatRelativeTime = (timestamp: string): string => {
if (!timestamp) return '';
const date = new Date(timestamp);
const date = parseTimestamp(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
@@ -306,36 +335,9 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
);
return (
<div className="flex h-full min-h-0">
<div className="flex h-full min-h-0 rounded-lg border overflow-hidden">
{/* Left Panel: Session List */}
<div className="w-64 flex-shrink-0 border-r flex flex-col min-h-0">
{/* Refresh Button */}
<div className="px-2 py-2 border-b shrink-0">
<Button
variant="ghost"
className="w-full h-9 text-sm text-muted-foreground"
onClick={loadSessions}
disabled={loadingSessions}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
'w-3.5 h-3.5 mr-1.5',
loadingSessions && 'animate-spin',
)}
>
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
</svg>
{t('bots.sessionMonitor.refresh')}
</Button>
</div>
<div className="w-60 flex-shrink-0 border-r flex flex-col min-h-0">
{/* Session List */}
<ScrollArea className="flex-1 min-h-0">
{loadingSessions && sessions.length === 0 ? (
@@ -347,14 +349,15 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
{t('bots.sessionMonitor.noSessions')}
</div>
) : (
<div className="p-1">
<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-3 py-2.5 rounded-md transition-colors',
'w-full text-left px-2.5 py-2 rounded-md transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)}
onClick={() => setSelectedSessionId(session.session_id)}
@@ -403,20 +406,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
{/* Right Panel: Messages */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{!selectedSessionId ? (
<div className="text-center text-muted-foreground py-12 text-lg flex-1 flex items-center justify-center">
<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-6 py-3 border-b shrink-0 flex items-center justify-between">
<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-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)}
@@ -433,6 +436,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
{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')}
@@ -462,40 +466,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="w-8 h-8"
onClick={() => loadMessages(selectedSessionId)}
disabled={loadingMessages}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={cn('w-4 h-4', loadingMessages && 'animate-spin')}
>
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
</svg>
</Button>
</div>
{/* Messages Area — matches DebugDialog style */}
{/* Messages Area */}
<ScrollArea
ref={messagesContainerRef}
className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black"
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
>
<div className="space-y-6">
<div className="space-y-4">
{loadingMessages ? (
<div className="text-center text-muted-foreground py-12 text-lg">
<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-lg">
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')}
</div>
) : (
@@ -511,21 +495,18 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
>
<div
className={cn(
'max-w-3xl px-5 py-3 rounded-2xl',
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
isUser
? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
? 'bg-primary/10 rounded-br-sm'
: 'bg-muted rounded-bl-sm',
msg.status === 'error' && 'ring-1 ring-red-400/50',
)}
>
{renderMessageContent(msg)}
{/* Role label + timestamp inside bubble, matching DebugDialog */}
{/* Role label + timestamp */}
<div
className={cn(
'text-xs mt-2 flex items-center gap-2',
isUser
? 'text-gray-600 dark:text-gray-300'
: 'text-gray-500 dark:text-gray-400',
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
)}
>
<span>
@@ -561,4 +542,6 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
</div>
</div>
);
}
});
export default BotSessionMonitor;
+8 -131
View File
@@ -1,144 +1,21 @@
'use client';
import { useEffect, useState } from 'react';
import styles from './botConfig.module.css';
import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';
import BotCard from '@/app/home/bots/components/bot-card/BotCard';
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Bot, Adapter } from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import BotDetailDialog from '@/app/home/bots/BotDetailDialog';
import { CustomApiError } from '@/app/infra/entities/common';
import { systemInfo } from '@/app/infra/http';
import BotDetailContent from './BotDetailContent';
export default function BotConfigPage() {
const { t } = useTranslation();
// 机器人详情dialog
const [detailDialogOpen, setDetailDialogOpen] = useState<boolean>(false);
const [botList, setBotList] = useState<BotCardVO[]>([]);
const [selectedBotId, setSelectedBotId] = useState<string>('');
const searchParams = useSearchParams();
const detailId = searchParams.get('id');
useEffect(() => {
getBotList();
}, []);
async function getBotList() {
const adapterListResp = await httpClient.getAdapters();
const adapterList = adapterListResp.adapters.map((adapter: Adapter) => {
return {
label: extractI18nObject(adapter.label),
value: adapter.name,
};
});
httpClient
.getBots()
.then((resp) => {
const botList: BotCardVO[] = resp.bots.map((bot: Bot) => {
return new BotCardVO({
id: bot.uuid || '',
iconURL: httpClient.getAdapterIconURL(bot.adapter),
name: bot.name,
description: bot.description,
adapter: bot.adapter,
adapterConfig: bot.adapter_config,
adapterLabel:
adapterList.find((item) => item.value === bot.adapter)?.label ||
bot.adapter.substring(0, 10),
usePipelineName: bot.use_pipeline_name || '',
enable: bot.enable || false,
});
});
setBotList(botList);
})
.catch((err) => {
console.error('get bot list error', err);
toast.error(t('bots.getBotListError') + (err as CustomApiError).msg);
});
}
function handleCreateBotClick() {
const maxBots = systemInfo.limitation?.max_bots ?? -1;
if (maxBots >= 0 && botList.length >= maxBots) {
toast.error(t('limitation.maxBotsReached', { max: maxBots }));
return;
}
setSelectedBotId('');
setDetailDialogOpen(true);
}
function selectBot(botUUID: string) {
setSelectedBotId(botUUID);
setDetailDialogOpen(true);
}
function handleFormSubmit() {
getBotList();
// setDetailDialogOpen(false);
}
function handleFormCancel() {
setDetailDialogOpen(false);
}
function handleBotDeleted() {
getBotList();
setDetailDialogOpen(false);
}
function handleNewBotCreated(botId: string) {
getBotList();
setSelectedBotId(botId);
if (detailId) {
return <BotDetailContent id={detailId} />;
}
return (
<div>
<BotDetailDialog
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
botId={selectedBotId || undefined}
onFormSubmit={handleFormSubmit}
onFormCancel={handleFormCancel}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
/>
{/* 注意:其余的返回内容需要保持在Spin组件外部 */}
<div className={`${styles.botListContainer}`}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateBotClick}
/>
{botList.map((cardVO) => {
return (
<div
key={cardVO.id}
onClick={() => {
selectBot(cardVO.id);
}}
>
<BotCard
botCardVO={cardVO}
setBotEnableCallback={(id, enable) => {
setBotList(
botList.map((bot) => {
if (bot.id === id) {
return { ...bot, enable: enable };
}
return bot;
}),
);
}}
/>
</div>
);
})}
</div>
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('bots.selectFromSidebar')}</p>
</div>
);
}