mirror of
https://github.com/langbot-app/LangBot.git
synced 2025-11-25 19:37:36 +08:00
Perf/combine entity dialogs (#1555)
* feat: combine bot settings and bot log dialogs * perf: dialog style when creating bot * perf: bot creation dialog * feat: combine pipeline dialogs * perf: ui * perf: move buttons * perf: ui layout in pipeline detail dialog * perf: remove debug button from pipeline card * perf: open pipeline dialog after creating * perf: placeholder in send input * perf: no close dialog when save done * fix: linter errors
This commit is contained in:
committed by
GitHub
parent
c34232a26c
commit
35f76cb7ae
@@ -21,17 +21,19 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-dialog": "^1.1.13",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-hover-card": "^1.1.13",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-slot": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
"@radix-ui/react-toggle": "^1.1.8",
|
||||
"@radix-ui/react-toggle-group": "^1.1.9",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"axios": "^1.8.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
262
web/src/app/home/bots/BotDetailDialog.tsx
Normal file
262
web/src/app/home/bots/BotDetailDialog.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
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 { 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// 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>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<BotForm
|
||||
initBotId={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onFormCancel={handleFormCancel}
|
||||
onBotDeleted={handleBotDeleted}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
hideButtons={true}
|
||||
/>
|
||||
</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-[50rem] 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"
|
||||
>
|
||||
<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')
|
||||
: t('bots.botLogTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
{activeMenu === 'config' && (
|
||||
<BotForm
|
||||
initBotId={botId}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onFormCancel={handleFormCancel}
|
||||
onBotDeleted={handleBotDeleted}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
hideButtons={true}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'logs' && botId && (
|
||||
<BotLogListComponent 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>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,21 +4,15 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function BotCard({
|
||||
botCardVO,
|
||||
clickLogIconCallback,
|
||||
setBotEnableCallback,
|
||||
}: {
|
||||
botCardVO: BotCardVO;
|
||||
clickLogIconCallback: (id: string) => void;
|
||||
setBotEnableCallback: (id: string, enable: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
function onClickLogIcon() {
|
||||
clickLogIconCallback(botCardVO.id);
|
||||
}
|
||||
|
||||
function setBotEnable(enable: boolean) {
|
||||
return httpClient.updateBot(botCardVO.id, {
|
||||
@@ -93,25 +87,6 @@ export default function BotCard({
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-auto h-[40px]"
|
||||
onClick={(e) => {
|
||||
onClickLogIcon();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="48"
|
||||
height="48"
|
||||
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>
|
||||
{t('bots.log')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,12 +67,14 @@ export default function BotForm({
|
||||
onFormCancel,
|
||||
onBotDeleted,
|
||||
onNewBotCreated,
|
||||
hideButtons = false,
|
||||
}: {
|
||||
initBotId?: string;
|
||||
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
|
||||
onFormCancel: () => void;
|
||||
onBotDeleted: () => void;
|
||||
onNewBotCreated: (botId: string) => void;
|
||||
hideButtons?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
@@ -282,7 +284,7 @@ export default function BotForm({
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
form.reset();
|
||||
// form.reset();
|
||||
// dynamicForm.resetFields();
|
||||
});
|
||||
} else {
|
||||
@@ -314,8 +316,6 @@ export default function BotForm({
|
||||
// dynamicForm.resetFields();
|
||||
});
|
||||
}
|
||||
setShowDynamicForm(false);
|
||||
console.log('set loading', false);
|
||||
}
|
||||
|
||||
function deleteBot() {
|
||||
@@ -365,6 +365,7 @@ export default function BotForm({
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="bot-form"
|
||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
@@ -527,42 +528,44 @@ export default function BotForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
{!initBotId && (
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={form.handleSubmit(onDynamicFormSubmit)}
|
||||
>
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
)}
|
||||
{initBotId && (
|
||||
<>
|
||||
{!hideButtons && (
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
{!initBotId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirmModal(true)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
type="submit"
|
||||
onClick={form.handleSubmit(onDynamicFormSubmit)}
|
||||
>
|
||||
{t('common.save')}
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onFormCancel()}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{initBotId && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirmModal(true)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={form.handleSubmit(onDynamicFormSubmit)}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onFormCancel()}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { BotLogManager } from '@/app/home/bots/bot-log/BotLogManager';
|
||||
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
import { BotLogCard } from '@/app/home/bots/bot-log/view/BotLogCard';
|
||||
import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';
|
||||
import styles from './botLog.module.css';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { debounce } from 'lodash';
|
||||
@@ -112,10 +112,7 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.botLogListContainer} px-6`}
|
||||
ref={listContainerRef}
|
||||
>
|
||||
<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)} />
|
||||
@@ -3,32 +3,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './botConfig.module.css';
|
||||
import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';
|
||||
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { i18nObj } from '@/i18n/I18nProvider';
|
||||
import { BotLogListComponent } from '@/app/home/bots/bot-log/view/BotLogListComponent';
|
||||
import BotDetailDialog from '@/app/home/bots/BotDetailDialog';
|
||||
|
||||
export default function BotConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
// 编辑机器人的modal
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
// 机器人日志的modal
|
||||
const [logModalOpen, setLogModalOpen] = useState<boolean>(false);
|
||||
// 机器人详情dialog
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState<boolean>(false);
|
||||
const [botList, setBotList] = useState<BotCardVO[]>([]);
|
||||
const [isEditForm, setIsEditForm] = useState(false);
|
||||
const [nowSelectedBotUUID, setNowSelectedBotUUID] = useState<string>();
|
||||
const [nowSelectedBotLog, setNowSelectedBotLog] = useState<string>();
|
||||
const [selectedBotId, setSelectedBotId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
getBotList();
|
||||
@@ -73,61 +62,46 @@ export default function BotConfigPage() {
|
||||
}
|
||||
|
||||
function handleCreateBotClick() {
|
||||
setIsEditForm(false);
|
||||
setNowSelectedBotUUID('');
|
||||
setModalOpen(true);
|
||||
setSelectedBotId('');
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
|
||||
function selectBot(botUUID: string) {
|
||||
setNowSelectedBotUUID(botUUID);
|
||||
setIsEditForm(true);
|
||||
setModalOpen(true);
|
||||
setSelectedBotId(botUUID);
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
|
||||
function onClickLogIcon(botId: string) {
|
||||
setNowSelectedBotLog(botId);
|
||||
setLogModalOpen(true);
|
||||
function handleFormSubmit() {
|
||||
getBotList();
|
||||
// setDetailDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleFormCancel() {
|
||||
setDetailDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleBotDeleted() {
|
||||
getBotList();
|
||||
setDetailDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleNewBotCreated(botId: string) {
|
||||
console.log('new bot created', botId);
|
||||
getBotList();
|
||||
setSelectedBotId(botId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.configPageContainer}>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>
|
||||
{isEditForm ? t('bots.editBot') : t('bots.createBot')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<BotForm
|
||||
initBotId={nowSelectedBotUUID}
|
||||
onFormSubmit={() => {
|
||||
getBotList();
|
||||
setModalOpen(false);
|
||||
}}
|
||||
onFormCancel={() => setModalOpen(false)}
|
||||
onBotDeleted={() => {
|
||||
getBotList();
|
||||
setModalOpen(false);
|
||||
}}
|
||||
onNewBotCreated={(botId) => {
|
||||
console.log('new bot created', botId);
|
||||
getBotList();
|
||||
selectBot(botId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={logModalOpen} onOpenChange={setLogModalOpen}>
|
||||
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-0">
|
||||
<DialogTitle>{t('bots.botLogTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<BotLogListComponent botId={nowSelectedBotLog || ''} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<BotDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
botId={selectedBotId || undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onFormCancel={handleFormCancel}
|
||||
onBotDeleted={handleBotDeleted}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
|
||||
{/* 注意:其余的返回内容需要保持在Spin组件外部 */}
|
||||
<div className={`${styles.botListContainer}`}>
|
||||
@@ -147,9 +121,6 @@ export default function BotConfigPage() {
|
||||
>
|
||||
<BotCard
|
||||
botCardVO={cardVO}
|
||||
clickLogIconCallback={(id) => {
|
||||
onClickLogIcon(id);
|
||||
}}
|
||||
setBotEnableCallback={(id, enable) => {
|
||||
setBotList(
|
||||
botList.map((bot) => {
|
||||
|
||||
214
web/src/app/home/pipelines/PipelineDetailDialog.tsx
Normal file
214
web/src/app/home/pipelines/PipelineDetailDialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar';
|
||||
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
|
||||
import DebugDialog from './components/debug-dialog/DebugDialog';
|
||||
import { PipelineFormEntity } from '@/app/infra/entities/pipeline';
|
||||
|
||||
interface PipelineDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pipelineId?: string;
|
||||
isEditMode?: boolean;
|
||||
isDefaultPipeline?: boolean;
|
||||
initValues?: PipelineFormEntity;
|
||||
onFinish: () => void;
|
||||
onNewPipelineCreated?: (pipelineId: string) => void;
|
||||
onDeletePipeline: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type DialogMode = 'config' | 'debug';
|
||||
|
||||
export default function PipelineDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pipelineId: propPipelineId,
|
||||
isEditMode = false,
|
||||
isDefaultPipeline = false,
|
||||
initValues,
|
||||
onFinish,
|
||||
onNewPipelineCreated,
|
||||
onDeletePipeline,
|
||||
onCancel,
|
||||
}: PipelineDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pipelineId, setPipelineId] = useState<string | undefined>(
|
||||
propPipelineId,
|
||||
);
|
||||
const [currentMode, setCurrentMode] = useState<DialogMode>('config');
|
||||
|
||||
useEffect(() => {
|
||||
setPipelineId(propPipelineId);
|
||||
setCurrentMode('config');
|
||||
}, [propPipelineId, open]);
|
||||
|
||||
const handleFinish = () => {
|
||||
onFinish();
|
||||
};
|
||||
|
||||
const handleNewPipelineCreated = (newPipelineId: string) => {
|
||||
setPipelineId(newPipelineId);
|
||||
setCurrentMode('config');
|
||||
if (onNewPipelineCreated) {
|
||||
onNewPipelineCreated(newPipelineId);
|
||||
}
|
||||
};
|
||||
|
||||
const menu = [
|
||||
{
|
||||
key: 'config',
|
||||
label: t('pipelines.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: 'debug',
|
||||
label: t('pipelines.debugChat'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const getDialogTitle = () => {
|
||||
if (currentMode === 'config') {
|
||||
return isEditMode
|
||||
? t('pipelines.editPipeline')
|
||||
: t('pipelines.createPipeline');
|
||||
}
|
||||
return t('pipelines.debugDialog.title');
|
||||
};
|
||||
|
||||
// 创建新流水线时的对话框
|
||||
if (!isEditMode) {
|
||||
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('pipelines.createPipeline')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<PipelineFormComponent
|
||||
initValues={initValues}
|
||||
isDefaultPipeline={isDefaultPipeline}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
isEditMode={isEditMode}
|
||||
pipelineId={pipelineId}
|
||||
disableForm={false}
|
||||
showButtons={true}
|
||||
onDeletePipeline={onDeletePipeline}
|
||||
onCancel={() => {
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 编辑流水线时的对话框
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[50rem] h-[75vh] flex">
|
||||
<SidebarProvider className="items-start w-full flex h-full min-h-0">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden md:flex h-full min-h-0 w-40 border-r bg-white"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{menu.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={currentMode === item.key}
|
||||
onClick={() => setCurrentMode(item.key as DialogMode)}
|
||||
>
|
||||
<a href="#">
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
<main className="flex flex-1 flex-col h-full min-h-0">
|
||||
<DialogHeader
|
||||
className="px-6 pt-6 pb-4 shrink-0"
|
||||
style={{ height: '4rem' }}
|
||||
>
|
||||
<DialogTitle>{getDialogTitle()}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className="flex-1 auto px-6 pb-4 w-full"
|
||||
style={{ height: 'calc(100% - 4rem)' }}
|
||||
>
|
||||
{currentMode === 'config' && (
|
||||
<PipelineFormComponent
|
||||
initValues={initValues}
|
||||
isDefaultPipeline={isDefaultPipeline}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
isEditMode={isEditMode}
|
||||
pipelineId={pipelineId}
|
||||
disableForm={false}
|
||||
showButtons={true}
|
||||
onDeletePipeline={onDeletePipeline}
|
||||
onCancel={() => {
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentMode === 'debug' && pipelineId && (
|
||||
<DebugDialog
|
||||
open={true}
|
||||
pipelineId={pipelineId}
|
||||
isEmbedded={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { DialogContent } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Message } from '@/app/infra/entities/message';
|
||||
import { toast } from 'sonner';
|
||||
import AtBadge from './AtBadge';
|
||||
|
||||
interface MessageComponent {
|
||||
type: 'At' | 'Plain';
|
||||
target?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface DebugDialogProps {
|
||||
open: boolean;
|
||||
pipelineId: string;
|
||||
isEmbedded?: boolean;
|
||||
}
|
||||
|
||||
export default function DebugDialog({
|
||||
open,
|
||||
pipelineId,
|
||||
isEmbedded = false,
|
||||
}: DebugDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
||||
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showAtPopover, setShowAtPopover] = useState(false);
|
||||
const [hasAt, setHasAt] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedPipelineId(pipelineId);
|
||||
loadMessages(pipelineId);
|
||||
}
|
||||
}, [open, pipelineId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadMessages(selectedPipelineId);
|
||||
}
|
||||
}, [sessionType, selectedPipelineId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(event.target as Node) &&
|
||||
!inputRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setShowAtPopover(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAtPopover) {
|
||||
setIsHovering(true);
|
||||
}
|
||||
}, [showAtPopover]);
|
||||
|
||||
const loadMessages = async (pipelineId: string) => {
|
||||
try {
|
||||
const response = await httpClient.getWebChatHistoryMessages(
|
||||
pipelineId,
|
||||
sessionType,
|
||||
);
|
||||
setMessages(response.messages);
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (sessionType === 'group') {
|
||||
if (value.endsWith('@')) {
|
||||
setShowAtPopover(true);
|
||||
} else if (showAtPopover && (!value.includes('@') || value.length > 1)) {
|
||||
setShowAtPopover(false);
|
||||
}
|
||||
}
|
||||
setInputValue(value);
|
||||
};
|
||||
|
||||
const handleAtSelect = () => {
|
||||
setHasAt(true);
|
||||
setShowAtPopover(false);
|
||||
setInputValue(inputValue.slice(0, -1));
|
||||
};
|
||||
|
||||
const handleAtRemove = () => {
|
||||
setHasAt(false);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (showAtPopover) {
|
||||
handleAtSelect();
|
||||
} else {
|
||||
sendMessage();
|
||||
}
|
||||
} else if (e.key === 'Backspace' && hasAt && inputValue === '') {
|
||||
handleAtRemove();
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputValue.trim() && !hasAt) return;
|
||||
|
||||
try {
|
||||
const messageChain = [];
|
||||
|
||||
let text_content = inputValue.trim();
|
||||
if (hasAt) {
|
||||
text_content = ' ' + text_content;
|
||||
}
|
||||
|
||||
if (hasAt) {
|
||||
messageChain.push({
|
||||
type: 'At',
|
||||
target: 'webchatbot',
|
||||
});
|
||||
}
|
||||
messageChain.push({
|
||||
type: 'Plain',
|
||||
text: text_content,
|
||||
});
|
||||
|
||||
if (hasAt) {
|
||||
// for showing
|
||||
text_content = '@webchatbot' + text_content;
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: -1,
|
||||
role: 'user',
|
||||
content: text_content,
|
||||
timestamp: new Date().toISOString(),
|
||||
message_chain: messageChain,
|
||||
};
|
||||
|
||||
setMessages((prevMessages) => [...prevMessages, userMessage]);
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
|
||||
const response = await httpClient.sendWebChatMessage(
|
||||
sessionType,
|
||||
messageChain,
|
||||
selectedPipelineId,
|
||||
120000,
|
||||
);
|
||||
|
||||
setMessages((prevMessages) => [...prevMessages, response.message]);
|
||||
} catch (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any
|
||||
) {
|
||||
console.log(error, 'type of error', typeof error);
|
||||
console.error('Failed to send message:', error);
|
||||
|
||||
if (!error.message.includes('timeout') && sessionType === 'person') {
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
}
|
||||
} finally {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessageContent = (message: Message) => {
|
||||
return (
|
||||
<span className="text-base leading-relaxed align-middle whitespace-pre-wrap">
|
||||
{(message.message_chain as MessageComponent[]).map(
|
||||
(component, index) => {
|
||||
if (component.type === 'At') {
|
||||
return (
|
||||
<AtBadge
|
||||
key={index}
|
||||
targetName={component.target || ''}
|
||||
readonly={true}
|
||||
/>
|
||||
);
|
||||
} else if (component.type === 'Plain') {
|
||||
return <span key={index}>{component.text}</span>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className="flex flex-1 h-full min-h-0">
|
||||
<div className="w-14 bg-white p-2 pl-0 flex-shrink-0 flex flex-col justify-start gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-10 h-10 justify-center rounded-md transition-none ${
|
||||
sessionType === 'person'
|
||||
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
|
||||
: 'bg-white text-gray-800 hover:bg-gray-100'
|
||||
} border-0 shadow-none`}
|
||||
onClick={() => setSessionType('person')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-10 h-10 justify-center rounded-md transition-none ${
|
||||
sessionType === 'group'
|
||||
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
|
||||
: 'bg-white text-gray-800 hover:bg-gray-100'
|
||||
} border-0 shadow-none`}
|
||||
onClick={() => setSessionType('group')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<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>
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white">
|
||||
<div className="space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
{t('pipelines.debugDialog.noMessages')}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id + message.timestamp}
|
||||
className={cn(
|
||||
'flex',
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-md px-5 py-3 rounded-2xl',
|
||||
message.role === 'user'
|
||||
? 'bg-[#2288ee] text-white rounded-br-none'
|
||||
: 'bg-gray-100 text-gray-900 rounded-bl-none',
|
||||
)}
|
||||
>
|
||||
{renderMessageContent(message)}
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-2',
|
||||
message.role === 'user'
|
||||
? 'text-white/70'
|
||||
: 'text-gray-500',
|
||||
)}
|
||||
>
|
||||
{message.role === 'user'
|
||||
? t('pipelines.debugDialog.userMessage')
|
||||
: t('pipelines.debugDialog.botMessage')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-4 pb-0 bg-white flex gap-2">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{hasAt && (
|
||||
<AtBadge targetName="webchatbot" onRemove={handleAtRemove} />
|
||||
)}
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={t('pipelines.debugDialog.inputPlaceholder', {
|
||||
type:
|
||||
sessionType === 'person'
|
||||
? t('pipelines.debugDialog.privateChat')
|
||||
: t('pipelines.debugDialog.groupChat'),
|
||||
})}
|
||||
className="flex-1 rounded-md px-3 py-2 border border-gray-300 focus:border-[#2288ee] transition-none text-base"
|
||||
/>
|
||||
{showAtPopover && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-white shadow-lg"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
|
||||
isHovering ? 'bg-gray-100' : 'bg-white',
|
||||
)}
|
||||
onClick={handleAtSelect}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<span>
|
||||
@webchatbot - {t('pipelines.debugDialog.atTips')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={!inputValue.trim() && !hasAt}
|
||||
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none"
|
||||
>
|
||||
<>{t('pipelines.debugDialog.send')}</>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果是嵌入模式,直接返回内容
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 min-h-0 flex flex-col">{renderContent()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 原有的Dialog包装
|
||||
return (
|
||||
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white">
|
||||
{renderContent()}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,10 @@
|
||||
import styles from './pipelineCard.module.css';
|
||||
import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function PipelineCard({
|
||||
cardVO,
|
||||
onDebug,
|
||||
}: {
|
||||
cardVO: PipelineCardVO;
|
||||
onDebug: (pipelineId: string) => void;
|
||||
}) {
|
||||
export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDebugClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDebug(cardVO.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.cardContainer}`}>
|
||||
<div className={`${styles.basicInfoContainer}`}>
|
||||
@@ -61,22 +49,6 @@ export default function PipelineCard({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDebugClick}
|
||||
title={t('pipelines.chat')}
|
||||
className="mt-auto"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={styles.debugButtonIcon}
|
||||
>
|
||||
<path d="M13 19.9C15.2822 19.4367 17 17.419 17 15V12C17 11.299 16.8564 10.6219 16.5846 10H7.41538C7.14358 10.6219 7 11.299 7 12V15C7 17.419 8.71776 19.4367 11 19.9V14H13V19.9ZM5.5358 17.6907C5.19061 16.8623 5 15.9534 5 15H2V13H5V12C5 11.3573 5.08661 10.7348 5.2488 10.1436L3.0359 8.86602L4.0359 7.13397L6.05636 8.30049C6.11995 8.19854 6.18609 8.09835 6.25469 8H17.7453C17.8139 8.09835 17.88 8.19854 17.9436 8.30049L19.9641 7.13397L20.9641 8.86602L18.7512 10.1436C18.9134 10.7348 19 11.3573 19 12V13H22V15H19C19 15.9534 18.8094 16.8623 18.4642 17.6907L20.9641 19.134L19.9641 20.866L17.4383 19.4077C16.1549 20.9893 14.1955 22 12 22C9.80453 22 7.84512 20.9893 6.56171 19.4077L4.0359 20.866L3.0359 19.134L5.5358 17.6907ZM8 6C8 3.79086 9.79086 2 12 2C14.2091 2 16 3.79086 16 6H8Z"></path>
|
||||
</svg>
|
||||
{t('pipelines.chat')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,15 +22,14 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { i18nObj } from '@/i18n/I18nProvider';
|
||||
|
||||
@@ -41,17 +40,25 @@ export default function PipelineFormComponent({
|
||||
onNewPipelineCreated,
|
||||
isEditMode,
|
||||
pipelineId,
|
||||
showButtons = true,
|
||||
onDeletePipeline,
|
||||
onCancel,
|
||||
}: {
|
||||
pipelineId?: string;
|
||||
isDefaultPipeline: boolean;
|
||||
isEditMode: boolean;
|
||||
disableForm: boolean;
|
||||
showButtons?: boolean;
|
||||
// 这里的写法很不安全不规范,未来流水线需要重新整理
|
||||
initValues?: PipelineFormEntity;
|
||||
onFinish: () => void;
|
||||
onNewPipelineCreated: (pipelineId: string) => void;
|
||||
onDeletePipeline: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const formSchema = isEditMode
|
||||
? z.object({
|
||||
basic: z.object({
|
||||
@@ -98,7 +105,6 @@ export default function PipelineFormComponent({
|
||||
useState<PipelineConfigTab>();
|
||||
const [outputConfigTabSchema, setOutputConfigTabSchema] =
|
||||
useState<PipelineConfigTab>();
|
||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
@@ -306,187 +312,191 @@ export default function PipelineFormComponent({
|
||||
);
|
||||
}
|
||||
|
||||
function deletePipeline() {
|
||||
httpClient
|
||||
.deletePipeline(pipelineId || '')
|
||||
.then(() => {
|
||||
onFinish();
|
||||
toast.success(t('common.deleteSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('common.deleteError') + err.message);
|
||||
});
|
||||
}
|
||||
const handleDelete = () => {
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (pipelineId) {
|
||||
httpClient
|
||||
.deletePipeline(pipelineId)
|
||||
.then(() => {
|
||||
onDeletePipeline();
|
||||
setShowDeleteConfirm(false);
|
||||
toast.success(t('pipelines.deleteSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('pipelines.deleteError') + err.message);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<Dialog
|
||||
open={showDeleteConfirmModal}
|
||||
onOpenChange={setShowDeleteConfirmModal}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
{t('pipelines.deleteConfirmation')}
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirmModal(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
deletePipeline();
|
||||
setShowDeleteConfirmModal(false);
|
||||
}}
|
||||
>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleFormSubmit)}>
|
||||
<Tabs defaultValue={formLabelList[0].name}>
|
||||
<TabsList>
|
||||
{formLabelList.map((formLabel) => (
|
||||
<TabsTrigger key={formLabel.name} value={formLabel.name}>
|
||||
{formLabel.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{formLabelList.map((formLabel) => (
|
||||
<TabsContent
|
||||
key={formLabel.name}
|
||||
value={formLabel.name}
|
||||
className="pr-6"
|
||||
<>
|
||||
<div className="!max-w-[70vw] max-w-6xl h-full p-0 flex flex-col bg-white">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="pipeline-form"
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="h-full flex flex-col flex-1 min-h-0 mb-2"
|
||||
>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<Tabs
|
||||
defaultValue={formLabelList[0].name}
|
||||
className="h-full flex flex-col flex-1 min-h-0"
|
||||
>
|
||||
<h1 className="text-xl font-bold mb-4">{formLabel.label}</h1>
|
||||
<TabsList>
|
||||
{formLabelList.map((formLabel) => (
|
||||
<TabsTrigger key={formLabel.name} value={formLabel.name}>
|
||||
{formLabel.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{formLabel.name === 'basic' && (
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('common.name')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
<div
|
||||
id="pipeline-form-content"
|
||||
className="flex-1 overflow-y-auto min-h-0"
|
||||
>
|
||||
{formLabelList.map((formLabel) => (
|
||||
<TabsContent
|
||||
key={formLabel.name}
|
||||
value={formLabel.name}
|
||||
className="overflow-y-auto max-h-full"
|
||||
>
|
||||
{formLabel.name === 'basic' && (
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('common.name')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('common.description')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="basic.description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('common.description')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
{isEditMode && (
|
||||
<>
|
||||
{formLabel.name === 'ai' && aiConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{aiConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'ai'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formLabel.name === 'trigger' &&
|
||||
triggerConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{triggerConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'trigger'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formLabel.name === 'safety' &&
|
||||
safetyConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{safetyConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'safety'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formLabel.name === 'output' &&
|
||||
outputConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{outputConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'output'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditMode && (
|
||||
<>
|
||||
{formLabel.name === 'ai' && aiConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{aiConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'ai'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formLabel.name === 'trigger' && triggerConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{triggerConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'trigger'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formLabel.name === 'safety' && safetyConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{safetyConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'safety'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formLabel.name === 'output' && outputConfigTabSchema && (
|
||||
<div className="space-y-6">
|
||||
{outputConfigTabSchema.stages.map((stage) =>
|
||||
renderDynamicForms(stage, 'output'),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<div className="sticky bottom-0 left-0 right-0 bg-background border-t p-4 mt-4">
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
{isEditMode && isDefaultPipeline && (
|
||||
<span className="text-gray-500 text-[0.7rem]">
|
||||
{t('pipelines.defaultPipelineCannotDelete')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</form>
|
||||
{/* 按钮栏移到 Tabs 外部,始终固定底部 */}
|
||||
{showButtons && (
|
||||
<div className="flex justify-end gap-2 pt-4 border-t mb-0 bg-white sticky bottom-0 z-10">
|
||||
{isEditMode && !isDefaultPipeline && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setShowDeleteConfirmModal(true);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="cursor-pointer">
|
||||
{isEditMode && isDefaultPipeline && (
|
||||
<div className="text-gray-500 text-sm h-full flex items-center mr-2">
|
||||
{t('pipelines.defaultPipelineCannotDelete')}
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" form="pipeline-form">
|
||||
{isEditMode ? t('common.save') : t('common.submit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onFinish}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('pipelines.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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormLabel {
|
||||
label: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Pipeline } from '@/app/infra/entities/api';
|
||||
import { Message } from '@/app/infra/entities/message';
|
||||
import { toast } from 'sonner';
|
||||
import AtBadge from './AtBadge';
|
||||
|
||||
interface MessageComponent {
|
||||
type: 'At' | 'Plain';
|
||||
target?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface DebugDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pipelineId: string;
|
||||
}
|
||||
|
||||
export default function DebugDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
pipelineId,
|
||||
}: DebugDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState(pipelineId);
|
||||
const [sessionType, setSessionType] = useState<'person' | 'group'>('person');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
||||
const [showAtPopover, setShowAtPopover] = useState(false);
|
||||
const [hasAt, setHasAt] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedPipelineId(pipelineId);
|
||||
loadPipelines();
|
||||
loadMessages(pipelineId);
|
||||
}
|
||||
}, [open, pipelineId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadMessages(selectedPipelineId);
|
||||
}
|
||||
}, [sessionType, selectedPipelineId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(event.target as Node) &&
|
||||
!inputRef.current?.contains(event.target as Node)
|
||||
) {
|
||||
setShowAtPopover(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAtPopover) {
|
||||
setIsHovering(true);
|
||||
}
|
||||
}, [showAtPopover]);
|
||||
|
||||
const loadPipelines = async () => {
|
||||
try {
|
||||
const response = await httpClient.getPipelines();
|
||||
setPipelines(response.pipelines);
|
||||
} catch (error) {
|
||||
console.error('Failed to load pipelines:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMessages = async (pipelineId: string) => {
|
||||
try {
|
||||
const response = await httpClient.getWebChatHistoryMessages(
|
||||
pipelineId,
|
||||
sessionType,
|
||||
);
|
||||
setMessages(response.messages);
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (sessionType === 'group') {
|
||||
if (value.endsWith('@')) {
|
||||
setShowAtPopover(true);
|
||||
} else if (showAtPopover && (!value.includes('@') || value.length > 1)) {
|
||||
setShowAtPopover(false);
|
||||
}
|
||||
}
|
||||
setInputValue(value);
|
||||
};
|
||||
|
||||
const handleAtSelect = () => {
|
||||
setHasAt(true);
|
||||
setShowAtPopover(false);
|
||||
setInputValue(inputValue.slice(0, -1));
|
||||
};
|
||||
|
||||
const handleAtRemove = () => {
|
||||
setHasAt(false);
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (showAtPopover) {
|
||||
handleAtSelect();
|
||||
} else {
|
||||
sendMessage();
|
||||
}
|
||||
} else if (e.key === 'Backspace' && hasAt && inputValue === '') {
|
||||
handleAtRemove();
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputValue.trim() && !hasAt) return;
|
||||
|
||||
try {
|
||||
const messageChain = [];
|
||||
|
||||
let text_content = inputValue.trim();
|
||||
if (hasAt) {
|
||||
text_content = ' ' + text_content;
|
||||
}
|
||||
|
||||
if (hasAt) {
|
||||
messageChain.push({
|
||||
type: 'At',
|
||||
target: 'webchatbot',
|
||||
});
|
||||
}
|
||||
messageChain.push({
|
||||
type: 'Plain',
|
||||
text: text_content,
|
||||
});
|
||||
|
||||
if (hasAt) {
|
||||
// for showing
|
||||
text_content = '@webchatbot' + text_content;
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: -1,
|
||||
role: 'user',
|
||||
content: text_content,
|
||||
timestamp: new Date().toISOString(),
|
||||
message_chain: messageChain,
|
||||
};
|
||||
|
||||
setMessages((prevMessages) => [...prevMessages, userMessage]);
|
||||
setInputValue('');
|
||||
setHasAt(false);
|
||||
|
||||
const response = await httpClient.sendWebChatMessage(
|
||||
sessionType,
|
||||
messageChain,
|
||||
selectedPipelineId,
|
||||
120000,
|
||||
);
|
||||
|
||||
setMessages((prevMessages) => [...prevMessages, response.message]);
|
||||
} catch (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
error: any
|
||||
) {
|
||||
console.log(error, 'type of error', typeof error);
|
||||
console.error('Failed to send message:', error);
|
||||
|
||||
if (!error.message.includes('timeout') && sessionType === 'person') {
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
}
|
||||
} finally {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// const resetSession = async () => {
|
||||
// try {
|
||||
// await httpClient.resetWebChatSession(selectedPipelineId, sessionType);
|
||||
// setMessages([]);
|
||||
// } catch (error) {
|
||||
// console.error('Failed to reset session:', error);
|
||||
// }
|
||||
// };
|
||||
|
||||
const renderMessageContent = (message: Message) => {
|
||||
return (
|
||||
<span className="text-base leading-relaxed align-middle whitespace-pre-wrap">
|
||||
{(message.message_chain as MessageComponent[]).map(
|
||||
(component, index) => {
|
||||
if (component.type === 'At') {
|
||||
return (
|
||||
<AtBadge
|
||||
key={index}
|
||||
targetName={component.target || ''}
|
||||
readonly={true}
|
||||
/>
|
||||
);
|
||||
} else if (component.type === 'Plain') {
|
||||
return <span key={index}>{component.text}</span>;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white">
|
||||
<DialogHeader className="pl-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-4 font-bold">
|
||||
{t('pipelines.debugDialog.title')}
|
||||
<Select
|
||||
value={selectedPipelineId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedPipelineId(value);
|
||||
loadMessages(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-xl shadow-lg">
|
||||
{pipelines.map((pipeline) => (
|
||||
<SelectItem
|
||||
key={pipeline.uuid}
|
||||
value={pipeline.uuid || ''}
|
||||
className="rounded-lg"
|
||||
>
|
||||
{pipeline.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-1 h-full min-h-0 border-t">
|
||||
<div className="w-50 bg-white border-r p-6 pl-0 rounded-l-2xl flex-shrink-0 flex flex-col justify-start gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-center rounded-md px-4 py-6 text-base font-medium transition-none ${
|
||||
sessionType === 'person'
|
||||
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
|
||||
: 'bg-white text-gray-800 hover:bg-gray-100'
|
||||
} border-0 shadow-none`}
|
||||
onClick={() => setSessionType('person')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z"></path>
|
||||
</svg>
|
||||
{t('pipelines.debugDialog.privateChat')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`w-full justify-center rounded-md px-4 py-6 text-base font-medium transition-none ${
|
||||
sessionType === 'group'
|
||||
? 'bg-[#2288ee] text-white hover:bg-[#2288ee] hover:text-white'
|
||||
: 'bg-white text-gray-800 hover:bg-gray-100'
|
||||
} border-0 shadow-none`}
|
||||
onClick={() => setSessionType('group')}
|
||||
>
|
||||
<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>
|
||||
{t('pipelines.debugDialog.groupChat')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col w-[10rem] h-full min-h-0">
|
||||
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white">
|
||||
<div className="space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
{t('pipelines.debugDialog.noMessages')}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id + message.timestamp}
|
||||
className={cn(
|
||||
'flex',
|
||||
message.role === 'user'
|
||||
? 'justify-end'
|
||||
: 'justify-start',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-md px-5 py-3 rounded-2xl',
|
||||
message.role === 'user'
|
||||
? 'bg-[#2288ee] text-white rounded-br-none'
|
||||
: 'bg-gray-100 text-gray-900 rounded-bl-none',
|
||||
)}
|
||||
>
|
||||
{renderMessageContent(message)}
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-2',
|
||||
message.role === 'user'
|
||||
? 'text-white/70'
|
||||
: 'text-gray-500',
|
||||
)}
|
||||
>
|
||||
{message.role === 'user'
|
||||
? t('pipelines.debugDialog.userMessage')
|
||||
: t('pipelines.debugDialog.botMessage')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t p-4 pb-0 bg-white flex gap-2">
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{hasAt && (
|
||||
<AtBadge targetName="webchatbot" onRemove={handleAtRemove} />
|
||||
)}
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder={t('pipelines.debugDialog.inputPlaceholder')}
|
||||
className="flex-1 rounded-md px-3 py-2 border border-gray-300 focus:border-[#2288ee] transition-none text-base"
|
||||
/>
|
||||
{showAtPopover && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-white shadow-lg"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
|
||||
isHovering ? 'bg-gray-100' : 'bg-white',
|
||||
)}
|
||||
onClick={handleAtSelect}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
>
|
||||
<span>
|
||||
@webchatbot - {t('pipelines.debugDialog.atTips')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={!inputValue.trim() && !hasAt}
|
||||
className="rounded-md bg-[#2288ee] hover:bg-[#2288ee] w-20 text-white px-6 py-2 text-base font-medium transition-none flex items-center gap-2 shadow-none"
|
||||
>
|
||||
<>{t('pipelines.debugDialog.send')}</>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,18 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
|
||||
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';
|
||||
import PipelineCard from '@/app/home/pipelines/components/pipeline-card/PipelineCard';
|
||||
import { PipelineFormEntity } from '@/app/infra/entities/pipeline';
|
||||
import styles from './pipelineConfig.module.css';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DebugDialog from './debug-dialog/DebugDialog';
|
||||
import PipelineDialog from './PipelineDetailDialog';
|
||||
|
||||
export default function PluginConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const [modalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [isEditForm, setIsEditForm] = useState(false);
|
||||
const [pipelineList, setPipelineList] = useState<PipelineCardVO[]>([]);
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState('');
|
||||
@@ -31,11 +24,8 @@ export default function PluginConfigPage() {
|
||||
safety: {},
|
||||
output: {},
|
||||
});
|
||||
const [disableForm, setDisableForm] = useState(false);
|
||||
const [selectedPipelineIsDefault, setSelectedPipelineIsDefault] =
|
||||
useState(false);
|
||||
const [debugDialogOpen, setDebugDialogOpen] = useState(false);
|
||||
const [debugPipelineId, setDebugPipelineId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getPipelines();
|
||||
@@ -92,83 +82,77 @@ export default function PluginConfigPage() {
|
||||
trigger: value.pipeline.config.trigger,
|
||||
});
|
||||
setSelectedPipelineIsDefault(value.pipeline.is_default ?? false);
|
||||
setDisableForm(false);
|
||||
});
|
||||
}
|
||||
|
||||
const handleDebug = (pipelineId: string) => {
|
||||
setDebugPipelineId(pipelineId);
|
||||
setDebugDialogOpen(true);
|
||||
const handlePipelineClick = (pipelineId: string) => {
|
||||
setSelectedPipelineId(pipelineId);
|
||||
setIsEditForm(true);
|
||||
setDialogOpen(true);
|
||||
getSelectedPipelineForm(pipelineId);
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setIsEditForm(false);
|
||||
setSelectedPipelineId('');
|
||||
setSelectedPipelineFormValue({
|
||||
basic: {},
|
||||
ai: {},
|
||||
trigger: {},
|
||||
safety: {},
|
||||
output: {},
|
||||
});
|
||||
setSelectedPipelineIsDefault(false);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.configPageContainer}>
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="w-[700px] max-h-[80vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>
|
||||
{isEditForm
|
||||
? t('pipelines.editPipeline')
|
||||
: t('pipelines.createPipeline')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6">
|
||||
<PipelineFormComponent
|
||||
onNewPipelineCreated={(pipelineId) => {
|
||||
setDisableForm(true);
|
||||
setIsEditForm(true);
|
||||
setModalOpen(true);
|
||||
setSelectedPipelineId(pipelineId);
|
||||
getSelectedPipelineForm(pipelineId);
|
||||
}}
|
||||
onFinish={() => {
|
||||
getPipelines();
|
||||
setModalOpen(false);
|
||||
}}
|
||||
isEditMode={isEditForm}
|
||||
pipelineId={selectedPipelineId}
|
||||
disableForm={disableForm}
|
||||
initValues={selectedPipelineFormValue}
|
||||
isDefaultPipeline={selectedPipelineIsDefault}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<PipelineDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
pipelineId={selectedPipelineId || undefined}
|
||||
isEditMode={isEditForm}
|
||||
isDefaultPipeline={selectedPipelineIsDefault}
|
||||
initValues={selectedPipelineFormValue}
|
||||
onFinish={() => {
|
||||
getPipelines();
|
||||
}}
|
||||
onNewPipelineCreated={(pipelineId) => {
|
||||
getPipelines();
|
||||
setSelectedPipelineId(pipelineId);
|
||||
setIsEditForm(true);
|
||||
setDialogOpen(true);
|
||||
getSelectedPipelineForm(pipelineId);
|
||||
}}
|
||||
onDeletePipeline={() => {
|
||||
getPipelines();
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.pipelineListContainer}>
|
||||
<CreateCardComponent
|
||||
width={'100%'}
|
||||
height={'10rem'}
|
||||
plusSize={'90px'}
|
||||
onClick={() => {
|
||||
setIsEditForm(false);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
onClick={handleCreateNew}
|
||||
/>
|
||||
|
||||
{pipelineList.map((pipeline) => {
|
||||
return (
|
||||
<div
|
||||
key={pipeline.id}
|
||||
onClick={() => {
|
||||
setDisableForm(true);
|
||||
setIsEditForm(true);
|
||||
setModalOpen(true);
|
||||
setSelectedPipelineId(pipeline.id);
|
||||
getSelectedPipelineForm(pipeline.id);
|
||||
}}
|
||||
onClick={() => handlePipelineClick(pipeline.id)}
|
||||
>
|
||||
<PipelineCard cardVO={pipeline} onDebug={handleDebug} />
|
||||
<PipelineCard cardVO={pipeline} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DebugDialog
|
||||
open={debugDialogOpen}
|
||||
onOpenChange={setDebugDialogOpen}
|
||||
pipelineId={debugPipelineId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
109
web/src/components/ui/breadcrumb.tsx
Normal file
109
web/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn('hover:text-foreground transition-colors', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
28
web/src/components/ui/separator.tsx
Normal file
28
web/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
139
web/src/components/ui/sheet.tsx
Normal file
139
web/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = 'right',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right' &&
|
||||
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm',
|
||||
side === 'left' &&
|
||||
'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
|
||||
side === 'top' &&
|
||||
'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b',
|
||||
side === 'bottom' &&
|
||||
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn('flex flex-col gap-1.5 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn('text-foreground font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
726
web/src/components/ui/sidebar.tsx
Normal file
726
web/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, VariantProps } from 'class-variance-authority';
|
||||
import { PanelLeftIcon } from 'lucide-react';
|
||||
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = 'sidebar_state';
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = '16rem';
|
||||
const SIDEBAR_WIDTH_MOBILE = '18rem';
|
||||
const SIDEBAR_WIDTH_ICON = '3rem';
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = 'b';
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: 'expanded' | 'collapsed';
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within a SidebarProvider.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === 'function' ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? 'expanded' : 'collapsed';
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH,
|
||||
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
'group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = 'left',
|
||||
variant = 'sidebar',
|
||||
collapsible = 'offcanvas',
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
side?: 'left' | 'right';
|
||||
variant?: 'sidebar' | 'floating' | 'inset';
|
||||
collapsible?: 'offcanvas' | 'icon' | 'none';
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === 'none') {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
'bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
|
||||
'group-data-[collapsible=offcanvas]:w-0',
|
||||
'group-data-[side=right]:rotate-180',
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex',
|
||||
side === 'left'
|
||||
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
|
||||
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === 'floating' || variant === 'inset'
|
||||
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
|
||||
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-7', className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex',
|
||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
|
||||
'hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full',
|
||||
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
|
||||
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
'bg-background relative flex w-full flex-1 flex-col',
|
||||
'md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn('bg-background h-8 w-full shadow-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn('flex flex-col gap-2 p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn('bg-sidebar-border mx-2 w-auto', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
'text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn('w-full text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn('flex w-full min-w-0 flex-col gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn('group/menu-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
outline:
|
||||
'bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]',
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 text-sm',
|
||||
sm: 'h-7 text-xs',
|
||||
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === 'string') {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== 'collapsed' || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
// Increases the hit area of the button on mobile.
|
||||
'after:absolute after:-inset-2 md:after:hidden',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
showOnHover &&
|
||||
'peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
'text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none',
|
||||
'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground',
|
||||
'peer-data-[size=sm]/menu-button:top-1',
|
||||
'peer-data-[size=default]/menu-button:top-1.5',
|
||||
'peer-data-[size=lg]/menu-button:top-2.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
'--skeleton-width': width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
'border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn('group/menu-sub-item relative', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = 'md',
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean;
|
||||
size?: 'sm' | 'md';
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
'text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && 'text-sm',
|
||||
'group-data-[collapsible=icon]:hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
13
web/src/components/ui/skeleton.tsx
Normal file
13
web/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn('bg-accent animate-pulse rounded-md', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
61
web/src/components/ui/tooltip.tsx
Normal file
61
web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
21
web/src/hooks/use-mobile.ts
Normal file
21
web/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
};
|
||||
mql.addEventListener('change', onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return !!isMobile;
|
||||
}
|
||||
@@ -129,6 +129,8 @@ const enUS = {
|
||||
dateFormat: '{{month}}/{{day}}',
|
||||
setBotEnableError: 'Failed to set bot enable status',
|
||||
log: 'Log',
|
||||
configuration: 'Configuration',
|
||||
logs: 'Logs',
|
||||
},
|
||||
plugins: {
|
||||
title: 'Plugins',
|
||||
@@ -185,6 +187,8 @@ const enUS = {
|
||||
createPipeline: 'Create Pipeline',
|
||||
editPipeline: 'Edit Pipeline',
|
||||
chat: 'Chat',
|
||||
configuration: 'Configuration',
|
||||
debugChat: 'Debug Chat',
|
||||
getPipelineListError: 'Failed to get pipeline list: ',
|
||||
daysAgo: 'days ago',
|
||||
today: 'Today',
|
||||
@@ -204,6 +208,8 @@ const enUS = {
|
||||
deleteConfirmation:
|
||||
'Are you sure you want to delete this pipeline? Bots bound to this pipeline will not work.',
|
||||
defaultPipelineCannotDelete: 'Default pipeline cannot be deleted',
|
||||
deleteSuccess: 'Deleted successfully',
|
||||
deleteError: 'Delete failed: ',
|
||||
debugDialog: {
|
||||
title: 'Pipeline Chat',
|
||||
selectPipeline: 'Select Pipeline',
|
||||
@@ -212,7 +218,7 @@ const enUS = {
|
||||
groupChat: 'Group Chat',
|
||||
send: 'Send',
|
||||
reset: 'Reset Conversation',
|
||||
inputPlaceholder: 'Enter message...',
|
||||
inputPlaceholder: 'Send {{type}} message...',
|
||||
noMessages: 'No messages',
|
||||
userMessage: 'User',
|
||||
botMessage: 'Bot',
|
||||
|
||||
@@ -130,6 +130,8 @@ const jaJP = {
|
||||
dateFormat: '{{month}}月{{day}}日',
|
||||
setBotEnableError: 'ボットの有効状態の設定に失敗しました',
|
||||
log: 'ログ',
|
||||
configuration: '設定',
|
||||
logs: 'ログ',
|
||||
},
|
||||
plugins: {
|
||||
title: 'プラグイン',
|
||||
@@ -185,6 +187,8 @@ const jaJP = {
|
||||
createPipeline: 'パイプラインを作成',
|
||||
editPipeline: 'パイプラインを編集',
|
||||
chat: 'チャット',
|
||||
configuration: '設定',
|
||||
debugChat: 'チャットデバッグ',
|
||||
getPipelineListError: 'パイプラインリストの取得に失敗しました:',
|
||||
daysAgo: '日前',
|
||||
today: '今日',
|
||||
@@ -205,6 +209,8 @@ const jaJP = {
|
||||
deleteConfirmation:
|
||||
'本当にこのパイプラインを削除しますか?このパイプラインに紐付けられたボットは動作しなくなります。',
|
||||
defaultPipelineCannotDelete: 'デフォルトパイプラインは削除できません',
|
||||
deleteSuccess: '削除に成功しました',
|
||||
deleteError: '削除に失敗しました:',
|
||||
debugDialog: {
|
||||
title: 'パイプラインのチャット',
|
||||
selectPipeline: 'パイプラインを選択',
|
||||
@@ -213,7 +219,7 @@ const jaJP = {
|
||||
groupChat: 'グループチャット',
|
||||
send: '送信',
|
||||
reset: '会話をリセット',
|
||||
inputPlaceholder: 'メッセージを入力...',
|
||||
inputPlaceholder: '{{type}}メッセージを送信...',
|
||||
noMessages: 'メッセージがありません',
|
||||
userMessage: 'ユーザー',
|
||||
botMessage: 'ボット',
|
||||
|
||||
@@ -26,7 +26,7 @@ const zhHans = {
|
||||
save: '保存',
|
||||
saving: '保存中...',
|
||||
confirm: '确认',
|
||||
confirmDelete: '删除确认',
|
||||
confirmDelete: '确认删除',
|
||||
deleteConfirmation: '你确定要删除这个吗?',
|
||||
selectOption: '选择一个选项',
|
||||
required: '必填',
|
||||
@@ -127,6 +127,8 @@ const zhHans = {
|
||||
dateFormat: '{{month}}月{{day}}日',
|
||||
setBotEnableError: '设置机器人启用状态失败',
|
||||
log: '日志',
|
||||
configuration: '配置',
|
||||
logs: '日志',
|
||||
},
|
||||
plugins: {
|
||||
title: '插件管理',
|
||||
@@ -180,6 +182,8 @@ const zhHans = {
|
||||
createPipeline: '创建流水线',
|
||||
editPipeline: '编辑流水线',
|
||||
chat: '对话',
|
||||
configuration: '配置',
|
||||
debugChat: '对话调试',
|
||||
getPipelineListError: '获取流水线列表失败:',
|
||||
daysAgo: '天前',
|
||||
today: '今天',
|
||||
@@ -199,6 +203,8 @@ const zhHans = {
|
||||
deleteConfirmation:
|
||||
'你确定要删除这个流水线吗?已绑定此流水线的机器人将无法使用。',
|
||||
defaultPipelineCannotDelete: '默认流水线不可删除',
|
||||
deleteSuccess: '删除成功',
|
||||
deleteError: '删除失败:',
|
||||
debugDialog: {
|
||||
title: '流水线对话',
|
||||
selectPipeline: '选择流水线',
|
||||
@@ -207,7 +213,7 @@ const zhHans = {
|
||||
groupChat: '群聊',
|
||||
send: '发送',
|
||||
reset: '重置对话',
|
||||
inputPlaceholder: '输入消息...',
|
||||
inputPlaceholder: '发送 {{type}} 消息...',
|
||||
noMessages: '暂无消息',
|
||||
userMessage: '用户',
|
||||
botMessage: '机器人',
|
||||
|
||||
Reference in New Issue
Block a user