Merge branch 'rc/new-plugin' into refactor/new-plugin-system

This commit is contained in:
Junyan Qin
2025-08-24 21:40:02 +08:00
232 changed files with 11998 additions and 1440 deletions

1
web/.env.example Normal file
View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:5300

1
web/.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel

View File

@@ -1,36 +1,3 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Debug LangBot Frontend
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Please refer to the [Development Guide](https://docs.langbot.app/en/develop/dev-config.html) for more information.

918
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,6 @@
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev:local": "NEXT_PUBLIC_API_BASE_URL=http://localhost:5300 next dev --turbopack",
"dev:local:win": "set NEXT_PUBLIC_API_BASE_URL=http://localhost:5300 && next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
@@ -17,12 +15,16 @@
"prettier --write"
]
},
"overrides": {
"@radix-ui/react-focus-scope": "1.1.7"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.13",
@@ -38,11 +40,13 @@
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/postcss": "^4.1.5",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0",
"input-otp": "^1.4.2",
"lodash": "^4.17.21",
"lucide-react": "^0.507.0",
"next": "15.2.4",

View File

@@ -56,6 +56,15 @@
background: rgba(0, 0, 0, 0.35); /* 悬停加深 */
}
/* 暗黑模式下的滚动条 */
.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2); /* 半透明白色 */
}
.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.35); /* 悬停加深 */
}
/* 兼容 Edge */
@supports (-ms-ime-align: auto) {
body {
@@ -108,36 +117,36 @@
}
.dark {
--background: oklch(0.141 0.005 285.823);
--background: oklch(0.08 0.002 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card: oklch(0.12 0.004 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover: oklch(0.12 0.004 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--primary: oklch(0.62 0.2 255);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.18 0.004 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted: oklch(0.18 0.004 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent: oklch(0.18 0.004 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 10%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar: oklch(0.1 0.003 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-primary: oklch(0.62 0.2 255);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.18 0.004 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}

View File

@@ -127,10 +127,8 @@ export default function BotDetailDialog({
<BotForm
initBotId={undefined}
onFormSubmit={handleFormSubmit}
onFormCancel={handleFormCancel}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
hideButtons={true}
/>
</div>
<DialogFooter className="px-6 py-4 border-t shrink-0">
@@ -161,7 +159,7 @@ export default function BotDetailDialog({
<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"
className="hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black"
>
<SidebarContent>
<SidebarGroup>
@@ -199,10 +197,8 @@ export default function BotDetailDialog({
<BotForm
initBotId={botId}
onFormSubmit={handleFormSubmit}
onFormCancel={handleFormCancel}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated}
hideButtons={true}
/>
)}
{activeMenu === 'logs' && botId && (

View File

@@ -6,12 +6,22 @@
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
padding: 1.2rem;
cursor: pointer;
transition: all 0.2s ease;
}
:global(.dark) .cardContainer {
background-color: #1f1f22;
box-shadow: 0;
}
.cardContainer:hover {
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
}
:global(.dark) .cardContainer:hover {
box-shadow: 0;
}
.iconBasicInfoContainer {
width: 100%;
height: 100%;
@@ -47,6 +57,11 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #1a1a1a;
}
:global(.dark) .basicInfoName {
color: #f0f0f0;
}
.basicInfoDescription {
@@ -58,6 +73,10 @@
text-overflow: ellipsis;
}
:global(.dark) .basicInfoDescription {
color: #888888;
}
.basicInfoAdapterContainer {
display: flex;
flex-direction: row;
@@ -71,12 +90,20 @@
color: #626262;
}
:global(.dark) .basicInfoAdapterIcon {
color: #a0a0a0;
}
.basicInfoAdapterLabel {
font-size: 1.2rem;
font-weight: 500;
color: #626262;
}
:global(.dark) .basicInfoAdapterLabel {
color: #a0a0a0;
}
.basicInfoPipelineContainer {
display: flex;
flex-direction: row;
@@ -90,12 +117,20 @@
margin-top: 0.2rem;
}
:global(.dark) .basicInfoPipelineIcon {
color: #a0a0a0;
}
.basicInfoPipelineLabel {
font-size: 1.2rem;
font-weight: 500;
color: #626262;
}
:global(.dark) .basicInfoPipelineLabel {
color: #a0a0a0;
}
.bigText {
white-space: nowrap;
overflow: hidden;

View File

@@ -64,17 +64,13 @@ const getFormSchema = (t: (key: string) => string) =>
export default function BotForm({
initBotId,
onFormSubmit,
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);
@@ -214,6 +210,7 @@ export default function BotForm({
});
setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap);
}
async function getBotConfig(
botId: string,
): Promise<z.infer<typeof formSchema>> {
@@ -397,7 +394,7 @@ export default function BotForm({
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} {...field}>
<SelectTrigger>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
@@ -470,7 +467,7 @@ export default function BotForm({
}}
value={field.value}
>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('bots.selectAdapter')} />
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
@@ -527,45 +524,6 @@ export default function BotForm({
</div>
)}
</div>
{!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="submit"
onClick={form.handleSubmit(onDynamicFormSubmit)}
>
{t('common.submit')}
</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>
)}
</form>
</Form>
</div>

View File

@@ -18,6 +18,11 @@
cursor: pointer;
}
:global(.dark) .botLogCardContainer {
background-color: #1f1f22;
border: 1px solid #2a2a2e;
}
.listHeader {
width: 100%;
height: 2.5rem;

View File

@@ -92,7 +92,7 @@ export default function BotConfigPage() {
}
return (
<div className={styles.configPageContainer}>
<div>
<BotDetailDialog
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}

View File

@@ -50,6 +50,9 @@ export default function DynamicFormComponent({
case 'llm-model-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-selector':
fieldSchema = z.string();
break;
case 'prompt-editor':
fieldSchema = z.array(
z.object({

View File

@@ -17,6 +17,7 @@ import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { LLMModel } from '@/app/infra/entities/api';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { toast } from 'sonner';
import {
HoverCard,
@@ -25,6 +26,7 @@ import {
} from '@/components/ui/hover-card';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Textarea } from '@/components/ui/textarea';
export default function DynamicFormItemComponent({
config,
@@ -35,6 +37,7 @@ export default function DynamicFormItemComponent({
field: ControllerRenderProps<any, any>;
}) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const { t } = useTranslation();
useEffect(() => {
@@ -50,6 +53,19 @@ export default function DynamicFormItemComponent({
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR) {
httpClient
.getKnowledgeBases()
.then((resp) => {
setKnowledgeBases(resp.bases);
})
.catch((err) => {
toast.error('获取知识库列表失败:' + err.message);
});
}
}, [config.type]);
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
@@ -117,7 +133,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.SELECT:
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} />
</SelectTrigger>
<SelectContent>
@@ -135,7 +151,7 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.LLM_MODEL_SELECTOR:
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
@@ -249,6 +265,25 @@ export default function DynamicFormItemComponent({
</Select>
);
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
return (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="__none__">{t('knowledge.empty')}</SelectItem>
{knowledgeBases.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
{base.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
case DynamicFormItemType.PROMPT_EDITOR:
return (
<div className="space-y-2">
@@ -257,7 +292,7 @@ export default function DynamicFormItemComponent({
<div key={index} className="flex gap-2 items-center">
{/* 角色选择 */}
{index === 0 ? (
<div className="w-[120px] px-3 py-2 border rounded bg-gray-50 text-gray-500">
<div className="w-[120px] px-3 py-2 border rounded bg-gray-50 dark:bg-[#2a292e] text-gray-500 dark:text-white dark:border-gray-600">
system
</div>
) : (
@@ -269,7 +304,7 @@ export default function DynamicFormItemComponent({
field.onChange(newValue);
}}
>
<SelectTrigger className="w-[120px]">
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -281,7 +316,7 @@ export default function DynamicFormItemComponent({
</Select>
)}
{/* 内容输入 */}
<Input
<Textarea
className="w-[300px]"
value={item.content}
onChange={(e) => {

View File

@@ -1,27 +0,0 @@
import styles from './emptyAndCreate.module.css';
export default function EmptyAndCreateComponent({
title,
subTitle,
buttonText,
onButtonClick,
}: {
title: string;
subTitle: string;
buttonText: string;
onButtonClick: () => void;
}) {
return (
<div className={`${styles.emptyPageContainer}`}>
<div className={`${styles.emptyContainer}`}>
<div className={`${styles.emptyInfoContainer}`}>
<div className={`${styles.emptyInfoText}`}>{title}</div>
<div className={`${styles.emptyInfoSubText}`}>{subTitle}</div>
</div>
<div className={`${styles.emptyCreateButton}`} onClick={onButtonClick}>
{buttonText}
</div>
</div>
</div>
);
}

View File

@@ -1,54 +0,0 @@
.emptyPageContainer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
border: 1px solid #c5c5c5;
border-radius: 10px;
}
.emptyContainer {
width: 100%;
height: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;
}
.emptyCreateButton {
width: 200px;
height: 50px;
border-radius: 20px;
background-color: #2288ee;
color: #fff;
font-size: 20px;
font-weight: bold;
text-align: center;
line-height: 50px;
user-select: none;
}
.emptyCreateButton:hover {
background-color: #1b77d2;
}
.emptyInfoContainer {
width: 100%;
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
color: #353535;
}
.emptyInfoText {
font-size: 30px;
}
.emptyInfoSubText {
font-size: 28px;
}

View File

@@ -13,6 +13,10 @@
/* box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1); */
}
:global(.dark) .sidebarContainer {
background-color: #0a0a0b !important;
}
.langbotIconContainer {
width: 200px;
height: 70px;
@@ -21,32 +25,49 @@
align-items: center;
justify-content: center;
gap: 0.8rem;
}
.langbotIcon {
width: 2.8rem;
height: 2.8rem;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
border-radius: 8px;
}
.langbotIcon {
width: 2.8rem;
height: 2.8rem;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
border-radius: 8px;
}
.langbotTextContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 0.1rem;
}
:global(.dark) .langbotIcon {
box-shadow: 0 0 10px 0 rgba(255, 255, 255, 0.1);
}
.langbotText {
font-size: 1.4rem;
font-weight: 500;
}
.langbotTextContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 0.1rem;
}
.langbotVersion {
font-size: 0.8rem;
font-weight: 700;
color: #6c6c6c;
}
.langbotText {
font-size: 1.4rem;
font-weight: 500;
color: #1a1a1a;
}
:global(.dark) .langbotText {
font-size: 1.4rem;
font-weight: 500;
color: #f0f0f0 !important;
}
.langbotVersion {
font-size: 0.8rem;
font-weight: 700;
color: #6c6c6c;
}
:global(.dark) .langbotVersion {
font-size: 0.8rem;
font-weight: 700;
color: #a0a0a0 !important;
}
.sidebarTopContainer {
@@ -76,6 +97,7 @@
justify-content: flex-start;
cursor: pointer;
gap: 0.5rem;
transition: all 0.2s ease;
/* background-color: aqua; */
}
@@ -85,16 +107,40 @@
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
:global(.dark) .sidebarSelected {
background-color: #2288ee;
color: white;
box-shadow: 0 0 10px 0 rgba(34, 136, 238, 0.3);
}
.sidebarUnselected {
color: #6c6c6c;
}
:global(.dark) .sidebarUnselected {
color: #a0a0a0 !important;
}
.sidebarUnselected:hover {
background-color: rgba(34, 136, 238, 0.1);
color: #2288ee;
}
:global(.dark) .sidebarUnselected:hover {
background-color: rgba(34, 136, 238, 0.2);
color: #66baff;
}
.sidebarChildIcon {
width: 20px;
height: 20px;
background-color: rgba(96, 149, 209, 0);
}
.sidebarChildName {
color: inherit;
}
.sidebarBottomContainer {
width: 100%;
display: flex;

View File

@@ -11,6 +11,18 @@ import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConf
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { systemInfo } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
import { Moon, Sun, Monitor } from 'lucide-react';
import { useTheme } from 'next-themes';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { LanguageSelector } from '@/components/ui/language-selector';
import PasswordChangeDialog from '@/app/home/components/password-change-dialog/PasswordChangeDialog';
// TODO 侧边导航栏要加动画
export default function HomeSidebar({
@@ -27,8 +39,11 @@ export default function HomeSidebar({
}, [pathname]);
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const [popoverOpen, setPopoverOpen] = useState(false);
const [passwordChangeOpen, setPasswordChangeOpen] = useState(false);
const [languageSelectorOpen, setLanguageSelectorOpen] = useState(false);
useEffect(() => {
initSelect();
@@ -144,6 +159,11 @@ export default function HomeSidebar({
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else if (language === 'zh-Hant') {
window.open(
'https://docs.langbot.app/zh/insight/guide.html',
'_blank',
);
} else {
window.open(
'https://docs.langbot.app/en/insight/guide.html',
@@ -163,23 +183,113 @@ export default function HomeSidebar({
}
name={t('common.helpDocs')}
/>
<SidebarChild
onClick={() => {
handleLogout();
<Popover
open={popoverOpen}
onOpenChange={(open) => {
// 防止语言选择器打开时关闭popover
if (!open && languageSelectorOpen) return;
setPopoverOpen(open);
}}
isSelected={false}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4 18H6V20H18V4H6V6H4V3C4 2.44772 4.44772 2 5 2H19C19.5523 2 20 2.44772 20 3V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V18ZM6 11H13V13H6V16L1 12L6 8V11Z"></path>
</svg>
}
name={t('common.logout')}
/>
>
<PopoverTrigger>
<SidebarChild
onClick={() => {}}
isSelected={false}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 3C10.9 3 10 3.9 10 5C10 6.1 10.9 7 12 7C13.1 7 14 6.1 14 5C14 3.9 13.1 3 12 3ZM12 17C10.9 17 10 17.9 10 19C10 20.1 10.9 21 12 21C13.1 21 14 20.1 14 19C14 17.9 13.1 17 12 17ZM12 10C10.9 10 10 10.9 10 12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12C14 10.9 13.1 10 12 10Z"></path>
</svg>
}
name={t('common.accountOptions')}
/>
</PopoverTrigger>
<PopoverContent
side="right"
align="end"
className="w-auto p-4 flex flex-col gap-4"
>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">{t('common.theme')}</span>
<ToggleGroup
type="single"
value={theme}
onValueChange={(value) => {
if (value) setTheme(value);
}}
className="justify-start"
>
<ToggleGroupItem value="light" size="sm">
<Sun className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="dark" size="sm">
<Moon className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="system" size="sm">
<Monitor className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">
{t('common.language')}
</span>
<LanguageSelector
triggerClassName="w-full"
onOpenChange={setLanguageSelectorOpen}
/>
</div>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm font-medium">{t('common.account')}</span>
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
setPasswordChangeOpen(true);
setPopoverOpen(false);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 mr-2"
>
<path d="M6 8V7C6 3.68629 8.68629 1 12 1C15.3137 1 18 3.68629 18 7V8H20C20.5523 8 21 8.44772 21 9V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V9C3 8.44772 3.44772 8 4 8H6ZM19 10H5V20H19V10ZM11 15.7324C10.4022 15.3866 10 14.7403 10 14C10 12.8954 10.8954 12 12 12C13.1046 12 14 12.8954 14 14C14 14.7403 13.5978 15.3866 13 15.7324V18H11V15.7324ZM8 8H16V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V8Z"></path>
</svg>
{t('common.changePassword')}
</Button>
<Button
variant="ghost"
className="w-full justify-start font-normal"
onClick={() => {
handleLogout();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4 mr-2"
>
<path d="M4 18H6V20H18V4H6V6H4V3C4 2.44772 4.44772 2 5 2H19C19.5523 2 20 2.44772 20 3V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V18ZM6 11H13V13H6V16L1 12L6 8V11Z"></path>
</svg>
{t('common.logout')}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<PasswordChangeDialog
open={passwordChangeOpen}
onOpenChange={setPasswordChangeOpen}
/>
</div>
);
}

View File

@@ -47,6 +47,7 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/deploy/models/readme.html',
},
}),
new SidebarChildVO({
id: 'pipelines',
name: t('pipelines.title'),
@@ -67,6 +68,25 @@ export const sidebarConfigList = [
zh_Hans: 'https://docs.langbot.app/zh/deploy/pipelines/readme.html',
},
}),
new SidebarChildVO({
id: 'knowledge',
name: t('knowledge.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z"></path>
</svg>
),
route: '/home/knowledge',
description: t('knowledge.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/deploy/knowledge/readme.html',
zh_Hans: 'https://docs.langbot.app/zh/deploy/knowledge/readme.html',
},
}),
new SidebarChildVO({
id: 'plugins',
name: t('plugins.title'),

View File

@@ -17,6 +17,10 @@
color: #585858;
}
:global(.dark) .titleText {
color: #e0e0e0;
}
.subtitleText {
margin-left: 3.2rem;
font-size: 0.8rem;
@@ -25,8 +29,16 @@
align-items: center;
}
:global(.dark) .subtitleText {
color: #b0b0b0;
}
.helpLink {
margin-left: 0.2rem;
font-size: 0.8rem;
color: #8b8b8b;
}
:global(.dark) .helpLink {
color: #a0a0a0;
}

View File

@@ -0,0 +1,163 @@
'use client';
import * as React from 'react';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { httpClient } from '@/app/infra/http/HttpClient';
const getFormSchema = (t: (key: string) => string) =>
z
.object({
currentPassword: z
.string()
.min(1, { message: t('common.currentPasswordRequired') }),
newPassword: z
.string()
.min(1, { message: t('common.newPasswordRequired') }),
confirmNewPassword: z
.string()
.min(1, { message: t('common.confirmPasswordRequired') }),
})
.refine((data) => data.newPassword === data.confirmNewPassword, {
message: t('common.passwordsDoNotMatch'),
path: ['confirmNewPassword'],
});
interface PasswordChangeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function PasswordChangeDialog({
open,
onOpenChange,
}: PasswordChangeDialogProps) {
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsSubmitting(true);
try {
await httpClient.changePassword(
values.currentPassword,
values.newPassword,
);
toast.success(t('common.changePasswordSuccess'));
form.reset();
onOpenChange(false);
} catch {
toast.error(t('common.changePasswordFailed'));
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.changePassword')}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.currentPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterCurrentPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.newPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterNewPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmNewPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.confirmNewPassword')}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={t('common.enterConfirmPassword')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,253 @@
'use client';
import { useEffect, useState } 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 { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
// import { KnowledgeBase } from '@/app/infra/entities/api';
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
import KBRetrieve from '@/app/home/knowledge/components/kb-retrieve/KBRetrieve';
interface KBDetailDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
kbId?: string;
onFormCancel: () => void;
onKbDeleted: () => void;
onNewKbCreated: (kbId: string) => void;
onKbUpdated: (kbId: string) => void;
}
export default function KBDetailDialog({
open,
onOpenChange,
kbId: propKbId,
onFormCancel,
onKbDeleted,
onNewKbCreated,
onKbUpdated,
}: KBDetailDialogProps) {
const { t } = useTranslation();
const [kbId, setKbId] = useState<string | undefined>(propKbId);
const [activeMenu, setActiveMenu] = useState('metadata');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
useEffect(() => {
setKbId(propKbId);
setActiveMenu('metadata');
}, [propKbId, open]);
const menu = [
{
key: 'metadata',
label: t('knowledge.metadata'),
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: 'documents',
label: t('knowledge.documents'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
</svg>
),
},
{
key: 'retrieve',
label: t('knowledge.retrieve'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M18.031 16.617l4.283 4.282-1.415 1.415-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9 9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617zm-2.006-.742A6.977 6.977 0 0 0 18 11c0-3.868-3.133-7-7-7-3.868 0-7 3.132-7 7 0 3.867 3.132 7 7 7a6.977 6.977 0 0 0 4.875-1.975l.15-.15z"></path>
</svg>
),
},
];
const confirmDelete = () => {
httpClient.deleteKnowledgeBase(kbId ?? '').then(() => {
onKbDeleted();
});
setShowDeleteConfirm(false);
};
if (!kbId) {
// new kb
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('knowledge.createKnowledgeBase')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
{activeMenu === 'metadata' && (
<KBForm
initKbId={undefined}
onNewKbCreated={onNewKbCreated}
onKbUpdated={onKbUpdated}
/>
)}
{activeMenu === 'documents' && <div>documents</div>}
</div>
{activeMenu === 'metadata' && (
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button type="submit" form="kb-form">
{t('common.save')}
</Button>
<Button
type="button"
variant="outline"
onClick={onFormCancel}
>
{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 dark:bg-black"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{menu.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton
asChild
isActive={activeMenu === item.key}
onClick={() => setActiveMenu(item.key)}
>
<a href="#">
{item.icon}
<span>{item.label}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex flex-1 flex-col h-[75vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>
{activeMenu === 'metadata'
? t('knowledge.editKnowledgeBase')
: activeMenu === 'documents'
? t('knowledge.editDocument')
: t('knowledge.retrieveTest')}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
{activeMenu === 'metadata' && (
<KBForm
initKbId={kbId}
onNewKbCreated={onNewKbCreated}
onKbUpdated={onKbUpdated}
/>
)}
{activeMenu === 'documents' && <KBDoc kbId={kbId} />}
{activeMenu === 'retrieve' && <KBRetrieve kbId={kbId} />}
</div>
{activeMenu === 'metadata' && (
<DialogFooter className="px-6 py-4 border-t shrink-0">
<div className="flex justify-end gap-2">
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
>
{t('common.delete')}
</Button>
<Button type="submit" form="kb-form">
{t('common.save')}
</Button>
<Button
type="button"
variant="outline"
onClick={onFormCancel}
>
{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('knowledge.deleteKnowledgeBaseConfirmation')}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t('common.cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,144 @@
.cardContainer {
width: 100%;
height: 10rem;
background-color: #fff;
border-radius: 10px;
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
padding: 1.2rem;
cursor: pointer;
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 0.5rem;
transition: all 0.2s ease;
}
:global(.dark) .cardContainer {
background-color: #1f1f22;
box-shadow: 0;
}
.cardContainer:hover {
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
}
:global(.dark) .cardContainer:hover {
box-shadow: 0;
}
.basicInfoContainer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.4rem;
min-width: 0;
}
.basicInfoNameContainer {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.basicInfoNameText {
font-size: 1.4rem;
font-weight: 500;
color: #1a1a1a;
}
:global(.dark) .basicInfoNameText {
color: #f0f0f0;
}
.basicInfoDescriptionText {
font-size: 0.9rem;
font-weight: 400;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
color: #b1b1b1;
}
:global(.dark) .basicInfoDescriptionText {
color: #888888;
}
.basicInfoLastUpdatedTimeContainer {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.basicInfoUpdateTimeIcon {
width: 1.2rem;
height: 1.2rem;
color: #626262;
}
:global(.dark) .basicInfoUpdateTimeIcon {
color: #a0a0a0;
}
.basicInfoUpdateTimeText {
font-size: 1rem;
font-weight: 400;
color: #626262;
}
:global(.dark) .basicInfoUpdateTimeText {
color: #a0a0a0;
}
.operationContainer {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
gap: 0.5rem;
width: 8rem;
}
.operationDefaultBadge {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
.operationDefaultBadgeIcon {
width: 1.2rem;
height: 1.2rem;
color: #ffcd27;
}
:global(.dark) .operationDefaultBadgeIcon {
color: #fbbf24;
}
.operationDefaultBadgeText {
font-size: 1rem;
font-weight: 400;
color: #ffcd27;
}
:global(.dark) .operationDefaultBadgeText {
color: #fbbf24;
}
.bigText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1.4rem;
font-weight: bold;
max-width: 100%;
}
.debugButtonIcon {
width: 1.2rem;
height: 1.2rem;
}

View File

@@ -0,0 +1,36 @@
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
import { useTranslation } from 'react-i18next';
import styles from './KBCard.module.css';
export default function KBCard({ kbCardVO }: { kbCardVO: KnowledgeBaseVO }) {
const { t } = useTranslation();
return (
<div className={`${styles.cardContainer}`}>
<div className={`${styles.basicInfoContainer}`}>
<div className={`${styles.basicInfoNameContainer}`}>
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
{kbCardVO.name}
</div>
<div className={`${styles.basicInfoDescriptionText}`}>
{kbCardVO.description}
</div>
</div>
<div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>
<svg
className={`${styles.basicInfoUpdateTimeIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z"></path>
</svg>
<div className={`${styles.basicInfoUpdateTimeText}`}>
{t('knowledge.updateTime')}
{kbCardVO.lastUpdatedTimeAgo}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
export interface IKnowledgeBaseVO {
id: string;
name: string;
description: string;
embeddingModelUUID: string;
top_k: number;
lastUpdatedTimeAgo: string;
}
export class KnowledgeBaseVO implements IKnowledgeBaseVO {
id: string;
name: string;
description: string;
embeddingModelUUID: string;
top_k: number;
lastUpdatedTimeAgo: string;
constructor(props: IKnowledgeBaseVO) {
this.id = props.id;
this.name = props.name;
this.description = props.description;
this.embeddingModelUUID = props.embeddingModelUUID;
this.top_k = props.top_k;
this.lastUpdatedTimeAgo = props.lastUpdatedTimeAgo;
}
}

View File

@@ -0,0 +1,145 @@
import React, { useCallback, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
interface FileUploadZoneProps {
kbId: string;
onUploadSuccess: () => void;
onUploadError: (error: string) => void;
}
export default function FileUploadZone({
kbId,
onUploadSuccess,
onUploadError,
}: FileUploadZoneProps) {
const { t } = useTranslation();
const [isDragOver, setIsDragOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const handleUpload = useCallback(
async (file: File) => {
if (isUploading) return;
setIsUploading(true);
const toastId = toast.loading(t('knowledge.documentsTab.uploadingFile'));
try {
// Step 1: Upload file to server
const uploadResult = await httpClient.uploadDocumentFile(file);
// Step 2: Associate file with knowledge base
await httpClient.uploadKnowledgeBaseFile(kbId, uploadResult.file_id);
toast.success(t('knowledge.documentsTab.uploadSuccess'), {
id: toastId,
});
onUploadSuccess();
} catch (error) {
console.error('File upload failed:', error);
const errorMessage = t('knowledge.documentsTab.uploadError');
toast.error(errorMessage, { id: toastId });
onUploadError(errorMessage);
} finally {
setIsUploading(false);
}
},
[kbId, isUploading, onUploadSuccess, onUploadError],
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleUpload(files[0]);
}
},
[handleUpload],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
handleUpload(files[0]);
}
},
[handleUpload],
);
return (
<Card className="mb-4">
<CardContent className="p-4">
<div
className={`
relative border-2 border-dashed rounded-lg p-4 text-center transition-colors
${
isDragOver
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
${isUploading ? 'opacity-50 pointer-events-none' : ''}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
type="file"
id="file-upload"
className="hidden"
onChange={handleFileSelect}
accept=".pdf,.doc,.docx,.txt,.md,.html,.zip"
disabled={isUploading}
/>
<label htmlFor="file-upload" className="cursor-pointer block">
<div className="space-y-2">
<div className="mx-auto w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
</div>
<div>
<p className="text-base font-medium text-gray-900 dark:text-gray-100">
{isUploading
? t('knowledge.documentsTab.uploading')
: t('knowledge.documentsTab.dragAndDrop')}
</p>
<p className="text-xs text-gray-500 mt-1 dark:text-gray-400">
{t('knowledge.documentsTab.supportedFormats')}
</p>
</div>
</div>
</label>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBaseFile } from '@/app/infra/entities/api';
import { columns, DocumentFile } from './documents/columns';
import { DataTable } from './documents/data-table';
import FileUploadZone from './FileUploadZone';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
export default function KBDoc({ kbId }: { kbId: string }) {
const [documentsList, setDocumentsList] = useState<DocumentFile[]>([]);
const { t } = useTranslation();
useEffect(() => {
getDocumentsList();
const intervalId = setInterval(() => {
getDocumentsList();
}, 5000);
return () => {
clearInterval(intervalId);
};
}, [kbId]);
async function getDocumentsList() {
const resp = await httpClient.getKnowledgeBaseFiles(kbId);
setDocumentsList(
resp.files.map((file: KnowledgeBaseFile) => {
return {
uuid: file.uuid,
name: file.file_name,
status: file.status,
};
}),
);
}
const handleUploadSuccess = () => {
// Refresh document list after successful upload
getDocumentsList();
};
const handleUploadError = (error: string) => {
// Error messages are already handled by toast in FileUploadZone component
console.error('Upload failed:', error);
};
const handleDelete = (id: string) => {
httpClient
.deleteKnowledgeBaseFile(kbId, id)
.then(() => {
getDocumentsList();
toast.success(t('knowledge.documentsTab.fileDeleteSuccess'));
})
.catch((error) => {
console.error('Delete failed:', error);
toast.error(t('knowledge.documentsTab.fileDeleteFailed'));
});
};
return (
<div className="container mx-auto py-2">
<FileUploadZone
kbId={kbId}
onUploadSuccess={handleUploadSuccess}
onUploadError={handleUploadError}
/>
<DataTable columns={columns(handleDelete, t)} data={documentsList} />
</div>
);
}

View File

@@ -0,0 +1,97 @@
'use client';
import { ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { TFunction } from 'i18next';
export type DocumentFile = {
uuid: string;
name: string;
status: string;
};
export const columns = (
onDelete: (id: string) => void,
t: TFunction,
): ColumnDef<DocumentFile>[] => {
return [
{
accessorKey: 'name',
header: t('knowledge.documentsTab.name'),
},
{
accessorKey: 'status',
header: t('knowledge.documentsTab.status'),
cell: ({ row }) => {
const document = row.original;
switch (document.status) {
case 'processing':
return (
<Badge variant="secondary">
{t('knowledge.documentsTab.processing')}
</Badge>
);
case 'completed':
return (
<Badge variant="outline" className="bg-blue-500 text-white">
{t('knowledge.documentsTab.completed')}
</Badge>
);
case 'failed':
return (
<Badge variant="outline" className="bg-yellow-500 text-white">
{t('knowledge.documentsTab.failed')}
</Badge>
);
default:
return (
<Badge variant="outline" className="bg-gray-500 text-white">
{document.status}
</Badge>
);
}
},
},
{
id: 'actions',
cell: ({ row }) => {
const document = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">
{t('knowledge.documentsTab.actions')}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-white dark:bg-[#2a2a2e]"
>
<DropdownMenuLabel>
{t('knowledge.documentsTab.actions')}
</DropdownMenuLabel>
<DropdownMenuItem onClick={() => onDelete(document.uuid)}>
{t('knowledge.documentsTab.delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
};

View File

@@ -0,0 +1,81 @@
'use client';
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useTranslation } from 'react-i18next';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const { t } = useTranslation();
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{t('knowledge.documentsTab.noResults')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export interface IEmbeddingModelEntity {
label: string;
value: string;
}

View File

@@ -0,0 +1,267 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from '@/components/ui/form';
import { IEmbeddingModelEntity } from './ChooseEntity';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { KnowledgeBase } from '@/app/infra/entities/api';
import { toast } from 'sonner';
const getFormSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, { message: t('knowledge.kbNameRequired') }),
description: z
.string()
.min(1, { message: t('knowledge.kbDescriptionRequired') }),
embeddingModelUUID: z
.string()
.min(1, { message: t('knowledge.embeddingModelUUIDRequired') }),
top_k: z
.number()
.min(1, { message: t('knowledge.topKRequired') })
.max(30, { message: t('knowledge.topKMax') }),
});
export default function KBForm({
initKbId,
onNewKbCreated,
onKbUpdated,
}: {
initKbId?: string;
onNewKbCreated: (kbId: string) => void;
onKbUpdated: (kbId: string) => void;
}) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
description: t('knowledge.defaultDescription'),
embeddingModelUUID: '',
top_k: 5,
},
});
const [embeddingModelNameList, setEmbeddingModelNameList] = useState<
IEmbeddingModelEntity[]
>([]);
useEffect(() => {
getEmbeddingModelNameList().then(() => {
if (initKbId) {
getKbConfig(initKbId).then((val) => {
form.setValue('name', val.name);
form.setValue('description', val.description);
form.setValue('embeddingModelUUID', val.embeddingModelUUID);
form.setValue('top_k', val.top_k || 5);
});
}
});
}, []);
const getKbConfig = async (
kbId: string,
): Promise<z.infer<typeof formSchema>> => {
return new Promise((resolve) => {
httpClient.getKnowledgeBase(kbId).then((res) => {
resolve({
name: res.base.name,
description: res.base.description,
embeddingModelUUID: res.base.embedding_model_uuid,
top_k: res.base.top_k || 5,
});
});
});
};
const getEmbeddingModelNameList = async () => {
const resp = await httpClient.getProviderEmbeddingModels();
setEmbeddingModelNameList(
resp.models.map((item) => {
return {
label: item.name,
value: item.uuid,
};
}),
);
};
const onSubmit = (data: z.infer<typeof formSchema>) => {
console.log('data', data);
if (initKbId) {
// update knowledge base
const updateKb: KnowledgeBase = {
name: data.name,
description: data.description,
embedding_model_uuid: data.embeddingModelUUID,
top_k: data.top_k,
};
httpClient
.updateKnowledgeBase(initKbId, updateKb)
.then((res) => {
console.log('update knowledge base success', res);
onKbUpdated(res.uuid);
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
})
.catch((err) => {
console.error('update knowledge base failed', err);
toast.error(t('knowledge.updateKnowledgeBaseFailed'));
});
} else {
// create knowledge base
const newKb: KnowledgeBase = {
name: data.name,
description: data.description,
embedding_model_uuid: data.embeddingModelUUID,
top_k: data.top_k,
};
httpClient
.createKnowledgeBase(newKb)
.then((res) => {
console.log('create knowledge base success', res);
onNewKbCreated(res.uuid);
})
.catch((err) => {
console.error('create knowledge base failed', err);
});
}
};
return (
<>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="kb-form"
className="space-y-8"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.kbName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.kbDescription')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="embeddingModelUUID"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.embeddingModelUUID')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<div className="relative">
<Select
disabled={!!initKbId}
onValueChange={(value) => {
field.onChange(value);
console.log('value', value);
}}
value={field.value}
>
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('knowledge.selectEmbeddingModel')}
/>
</SelectTrigger>
<SelectContent className="fixed z-[1000]">
<SelectGroup>
{embeddingModelNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</FormControl>
<FormDescription>
{initKbId
? t('knowledge.cannotChangeEmbeddingModel')
: t('knowledge.embeddingModelDescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="top_k"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.topK')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) => field.onChange(Number(e.target.value))}
className="w-[180px] h-10 text-base appearance-none"
/>
</FormControl>
<FormDescription>
{t('knowledge.topKdescription')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { RetrieveResult, KnowledgeBaseFile } from '@/app/infra/entities/api';
import { toast } from 'sonner';
interface KBRetrieveProps {
kbId: string;
}
export default function KBRetrieve({ kbId }: KBRetrieveProps) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [results, setResults] = useState<RetrieveResult[]>([]);
const [files, setFiles] = useState<KnowledgeBaseFile[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadFiles = async () => {
try {
const response = await httpClient.getKnowledgeBaseFiles(kbId);
setFiles(response.files);
} catch (error) {
console.error('Failed to load files:', error);
}
};
loadFiles();
}, [kbId]);
const handleRetrieve = async () => {
if (!query.trim()) return;
setLoading(true);
try {
setResults([]);
const response = await httpClient.retrieveKnowledgeBase(kbId, query);
setResults(response.results);
} catch (error) {
console.error('Retrieve failed:', error);
toast.error(t('knowledge.retrieveError'));
} finally {
setLoading(false);
}
};
const getFileName = (fileId: string) => {
const file = files.find((f) => f.uuid === fileId);
return file?.file_name || fileId;
};
return (
<div className="space-y-4">
<div className="flex gap-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('knowledge.queryPlaceholder')}
onKeyPress={(e) => e.key === 'Enter' && handleRetrieve()}
/>
<Button onClick={handleRetrieve} disabled={loading || !query.trim()}>
{t('knowledge.query')}
</Button>
</div>
<div className="space-y-3">
{results.length === 0 && !loading && (
<p className="text-muted-foreground">{t('knowledge.noResults')}</p>
)}
{loading ? (
<p className="text-muted-foreground">{t('common.loading')}</p>
) : (
results.map((result) => (
<Card key={result.id} className="w-full">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex justify-between items-center">
<span>{getFileName(result.metadata.file_id)}</span>
<span className="text-xs text-muted-foreground">
{t('knowledge.distance')}: {result.distance.toFixed(4)}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">
{result.metadata.text}
</p>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
.configPageContainer {
width: 100%;
height: 100%;
}
.knowledgeListContainer {
width: 100%;
padding-left: 0.8rem;
padding-right: 0.8rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
gap: 2rem;
justify-items: stretch;
align-items: start;
}

View File

@@ -0,0 +1,115 @@
'use client';
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
import styles from './knowledgeBase.module.css';
import { useTranslation } from 'react-i18next';
import { useEffect, useState } from 'react';
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
import { httpClient } from '@/app/infra/http/HttpClient';
import { KnowledgeBase } from '@/app/infra/entities/api';
export default function KnowledgePage() {
const { t } = useTranslation();
const [knowledgeBaseList, setKnowledgeBaseList] = useState<KnowledgeBaseVO[]>(
[],
);
const [selectedKbId, setSelectedKbId] = useState<string>('');
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
useEffect(() => {
getKnowledgeBaseList();
}, []);
async function getKnowledgeBaseList() {
const resp = await httpClient.getKnowledgeBases();
setKnowledgeBaseList(
resp.bases.map((kb: KnowledgeBase) => {
const currentTime = new Date();
const lastUpdatedTimeAgo = Math.floor(
(currentTime.getTime() -
new Date(kb.updated_at ?? currentTime.getTime()).getTime()) /
1000 /
60 /
60 /
24,
);
const lastUpdatedTimeAgoText =
lastUpdatedTimeAgo > 0
? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`
: t('knowledge.today');
return new KnowledgeBaseVO({
id: kb.uuid || '',
name: kb.name,
description: kb.description,
embeddingModelUUID: kb.embedding_model_uuid,
top_k: kb.top_k ?? 5,
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
});
}),
);
}
const handleKBCardClick = (kbId: string) => {
setSelectedKbId(kbId);
setDetailDialogOpen(true);
};
const handleCreateKBClick = () => {
setSelectedKbId('');
setDetailDialogOpen(true);
};
const handleFormCancel = () => {
setDetailDialogOpen(false);
};
const handleKbDeleted = () => {
getKnowledgeBaseList();
setDetailDialogOpen(false);
};
const handleNewKbCreated = (newKbId: string) => {
getKnowledgeBaseList();
setSelectedKbId(newKbId);
setDetailDialogOpen(true);
};
const handleKbUpdated = () => {
getKnowledgeBaseList();
};
return (
<div>
<KBDetailDialog
open={detailDialogOpen}
onOpenChange={setDetailDialogOpen}
kbId={selectedKbId || undefined}
onFormCancel={handleFormCancel}
onKbDeleted={handleKbDeleted}
onNewKbCreated={handleNewKbCreated}
onKbUpdated={handleKbUpdated}
/>
<div className={styles.knowledgeListContainer}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateKBClick}
/>
{knowledgeBaseList.map((kb) => {
return (
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
<KBCard kbCardVO={kb} />
</div>
);
})}
</div>
</div>
);
}

View File

@@ -7,6 +7,19 @@
background-color: #eee;
}
:global(.dark) .homeLayoutContainer {
background-color: #0a0a0b;
}
/* 侧边栏区域 */
.sidebar {
background-color: #eee;
}
:global(.dark) .sidebar {
background-color: #0a0a0b;
}
/* 主内容区域 */
.main {
background-color: #fafafa;
@@ -23,6 +36,11 @@
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.05);
}
:global(.dark) .main {
background-color: #151518;
box-shadow: 0 0 6px 0 rgba(255, 255, 255, 0.05);
}
.mainContent {
padding: 1.5rem;
padding-left: 2rem;
@@ -30,3 +48,7 @@
overflow-y: auto;
background-color: #fafafa;
}
:global(.dark) .mainContent {
background-color: #151518;
}

View File

@@ -0,0 +1,7 @@
export interface ICreateEmbeddingField {
name: string;
model_provider: string;
url: string;
api_key: string;
extra_args?: string[];
}

View File

@@ -0,0 +1,128 @@
.cardContainer {
width: 100%;
height: 10rem;
background-color: #fff;
border-radius: 10px;
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
padding: 1.2rem;
cursor: pointer;
transition: all 0.2s ease;
}
:global(.dark) .cardContainer {
background-color: #1f1f22;
box-shadow: 0;
}
.cardContainer:hover {
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
}
:global(.dark) .cardContainer:hover {
box-shadow: 0;
}
.iconBasicInfoContainer {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
gap: 0.8rem;
user-select: none;
}
.iconImage {
width: 3.8rem;
height: 3.8rem;
margin: 0.2rem;
border-radius: 50%;
}
.basicInfoContainer {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
width: 100%;
}
.basicInfoText {
font-size: 1.4rem;
font-weight: bold;
color: #1a1a1a;
}
:global(.dark) .basicInfoText {
color: #f0f0f0;
}
.providerContainer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.2rem;
}
.providerIcon {
width: 1.2rem;
height: 1.2rem;
margin-top: 0.2rem;
color: #626262;
}
:global(.dark) .providerIcon {
color: #a0a0a0;
}
.providerLabel {
font-size: 1.2rem;
font-weight: 600;
color: #626262;
}
:global(.dark) .providerLabel {
color: #a0a0a0;
}
.baseURLContainer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.2rem;
width: calc(100% - 3rem);
}
.baseURLIcon {
width: 1.2rem;
height: 1.2rem;
color: #626262;
}
:global(.dark) .baseURLIcon {
color: #a0a0a0;
}
.baseURLText {
font-size: 1rem;
width: 100%;
color: #626262;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
:global(.dark) .baseURLText {
color: #a0a0a0;
}
.bigText {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1.4rem;
font-weight: bold;
max-width: 100%;
}

View File

@@ -0,0 +1,53 @@
import styles from './EmbeddingCard.module.css';
import { EmbeddingCardVO } from '@/app/home/models/component/embedding-card/EmbeddingCardVO';
export default function EmbeddingCard({ cardVO }: { cardVO: EmbeddingCardVO }) {
return (
<div className={`${styles.cardContainer}`}>
<div className={`${styles.iconBasicInfoContainer}`}>
<img
className={`${styles.iconImage}`}
src={cardVO.iconURL}
alt="icon"
/>
<div className={`${styles.basicInfoContainer}`}>
{/* 名称 */}
<div className={`${styles.basicInfoText} ${styles.bigText}`}>
{cardVO.name}
</div>
{/* 厂商 */}
<div className={`${styles.providerContainer}`}>
<svg
className={`${styles.providerIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="36"
height="36"
fill="currentColor"
>
<path d="M21 13.2422V20H22V22H2V20H3V13.2422C1.79401 12.435 1 11.0602 1 9.5C1 8.67286 1.22443 7.87621 1.63322 7.19746L4.3453 2.5C4.52393 2.1906 4.85406 2 5.21132 2H18.7887C19.1459 2 19.4761 2.1906 19.6547 2.5L22.3575 7.18172C22.7756 7.87621 23 8.67286 23 9.5C23 11.0602 22.206 12.435 21 13.2422ZM19 13.9725C18.8358 13.9907 18.669 14 18.5 14C17.2409 14 16.0789 13.478 15.25 12.6132C14.4211 13.478 13.2591 14 12 14C10.7409 14 9.5789 13.478 8.75 12.6132C7.9211 13.478 6.75911 14 5.5 14C5.331 14 5.16417 13.9907 5 13.9725V20H19V13.9725ZM5.78865 4L3.35598 8.21321C3.12409 8.59843 3 9.0389 3 9.5C3 10.8807 4.11929 12 5.5 12C6.53096 12 7.44467 11.3703 7.82179 10.4295C8.1574 9.59223 9.3426 9.59223 9.67821 10.4295C10.0553 11.3703 10.969 12 12 12C13.031 12 13.9447 11.3703 14.3218 10.4295C14.6574 9.59223 15.8426 9.59223 16.1782 10.4295C16.5553 11.3703 17.469 12 18.5 12C19.8807 12 21 10.8807 21 9.5C21 9.0389 20.8759 8.59843 20.6347 8.19746L18.2113 4H5.78865Z"></path>
</svg>
<span className={`${styles.providerLabel}`}>
{cardVO.providerLabel}
</span>
</div>
{/* baseURL */}
<div className={`${styles.baseURLContainer}`}>
<svg
className={`${styles.baseURLIcon}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="36"
height="36"
fill="rgba(98,98,98,1)"
>
<path d="M13.0607 8.11097L14.4749 9.52518C17.2086 12.2589 17.2086 16.691 14.4749 19.4247L14.1214 19.7782C11.3877 22.5119 6.95555 22.5119 4.22188 19.7782C1.48821 17.0446 1.48821 12.6124 4.22188 9.87874L5.6361 11.293C3.68348 13.2456 3.68348 16.4114 5.6361 18.364C7.58872 20.3166 10.7545 20.3166 12.7072 18.364L13.0607 18.0105C15.0133 16.0578 15.0133 12.892 13.0607 10.9394L11.6465 9.52518L13.0607 8.11097ZM19.7782 14.1214L18.364 12.7072C20.3166 10.7545 20.3166 7.58872 18.364 5.6361C16.4114 3.68348 13.2456 3.68348 11.293 5.6361L10.9394 5.98965C8.98678 7.94227 8.98678 11.1081 10.9394 13.0607L12.3536 14.4749L10.9394 15.8891L9.52518 14.4749C6.79151 11.7413 6.79151 7.30911 9.52518 4.57544L9.87874 4.22188C12.6124 1.48821 17.0446 1.48821 19.7782 4.22188C22.5119 6.95555 22.5119 11.3877 19.7782 14.1214Z"></path>
</svg>
<span className={`${styles.baseURLText}`}>{cardVO.baseURL}</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
export interface IEmbeddingCardVO {
id: string;
iconURL: string;
name: string;
providerLabel: string;
baseURL: string;
}
export class EmbeddingCardVO implements IEmbeddingCardVO {
id: string;
iconURL: string;
providerLabel: string;
name: string;
baseURL: string;
constructor(props: IEmbeddingCardVO) {
this.id = props.id;
this.iconURL = props.iconURL;
this.providerLabel = props.providerLabel;
this.name = props.name;
this.baseURL = props.baseURL;
}
}

View File

@@ -0,0 +1,579 @@
import { ICreateEmbeddingField } from '@/app/home/models/component/ICreateEmbeddingField';
import { useEffect, useState } from 'react';
import { IChooseRequesterEntity } from '@/app/home/models/component/ChooseRequesterEntity';
import { httpClient } from '@/app/infra/http/HttpClient';
import { EmbeddingModel } from '@/app/infra/entities/api';
import { UUID } from 'uuidjs';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import { i18nObj } from '@/i18n/I18nProvider';
const getExtraArgSchema = (t: (key: string) => string) =>
z
.object({
key: z.string().min(1, { message: t('models.keyNameRequired') }),
type: z.enum(['string', 'number', 'boolean']),
value: z.string(),
})
.superRefine((data, ctx) => {
if (data.type === 'number' && isNaN(Number(data.value))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('models.mustBeValidNumber'),
path: ['value'],
});
}
if (
data.type === 'boolean' &&
data.value !== 'true' &&
data.value !== 'false'
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('models.mustBeTrueOrFalse'),
path: ['value'],
});
}
});
const getFormSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, { message: t('models.modelNameRequired') }),
model_provider: z
.string()
.min(1, { message: t('models.modelProviderRequired') }),
url: z.string().min(1, { message: t('models.requestURLRequired') }),
api_key: z.string().min(1, { message: t('models.apiKeyRequired') }),
extra_args: z.array(getExtraArgSchema(t)).optional(),
});
export default function EmbeddingForm({
editMode,
initEmbeddingId,
onFormSubmit,
onFormCancel,
onEmbeddingDeleted,
}: {
editMode: boolean;
initEmbeddingId?: string;
onFormSubmit: () => void;
onFormCancel: () => void;
onEmbeddingDeleted: () => void;
}) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
model_provider: '',
url: '',
api_key: 'sk-xxxxx',
extra_args: [],
},
});
const [extraArgs, setExtraArgs] = useState<
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
>([]);
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
const [requesterNameList, setRequesterNameList] = useState<
IChooseRequesterEntity[]
>([]);
const [requesterDefaultURLList, setRequesterDefaultURLList] = useState<
string[]
>([]);
const [modelTesting, setModelTesting] = useState(false);
const [currentModelProvider, setCurrentModelProvider] = useState('');
useEffect(() => {
initEmbeddingModelFormComponent().then(() => {
if (editMode && initEmbeddingId) {
getEmbeddingConfig(initEmbeddingId).then((val) => {
form.setValue('name', val.name);
form.setValue('model_provider', val.model_provider);
setCurrentModelProvider(val.model_provider);
form.setValue('url', val.url);
form.setValue('api_key', val.api_key);
if (val.extra_args) {
const args = val.extra_args.map((arg) => {
const [key, value] = arg.split(':');
let type: 'string' | 'number' | 'boolean' = 'string';
if (!isNaN(Number(value))) {
type = 'number';
} else if (value === 'true' || value === 'false') {
type = 'boolean';
}
return {
key,
type,
value,
};
});
setExtraArgs(args);
form.setValue('extra_args', args);
}
});
} else {
form.reset();
}
});
}, []);
const addExtraArg = () => {
setExtraArgs([...extraArgs, { key: '', type: 'string', value: '' }]);
};
const updateExtraArg = (
index: number,
field: 'key' | 'type' | 'value',
value: string,
) => {
const newArgs = [...extraArgs];
newArgs[index] = {
...newArgs[index],
[field]: value,
};
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs);
};
const removeExtraArg = (index: number) => {
const newArgs = extraArgs.filter((_, i) => i !== index);
setExtraArgs(newArgs);
form.setValue('extra_args', newArgs);
};
async function initEmbeddingModelFormComponent() {
const requesterNameList =
await httpClient.getProviderRequesters('text-embedding');
setRequesterNameList(
requesterNameList.requesters.map((item) => {
return {
label: i18nObj(item.label),
value: item.name,
};
}),
);
setRequesterDefaultURLList(
requesterNameList.requesters.map((item) => {
const config = item.spec.config;
for (let i = 0; i < config.length; i++) {
if (config[i].name == 'base_url') {
return config[i].default?.toString() || '';
}
}
return '';
}),
);
}
async function getEmbeddingConfig(
id: string,
): Promise<ICreateEmbeddingField> {
const embeddingModel = await httpClient.getProviderEmbeddingModel(id);
const fakeExtraArgs = [];
const extraArgs = embeddingModel.model.extra_args as Record<string, string>;
for (const key in extraArgs) {
fakeExtraArgs.push(`${key}:${extraArgs[key]}`);
}
return {
name: embeddingModel.model.name,
model_provider: embeddingModel.model.requester,
url: embeddingModel.model.requester_config?.base_url,
api_key: embeddingModel.model.api_keys[0],
extra_args: fakeExtraArgs,
};
}
function handleFormSubmit(value: z.infer<typeof formSchema>) {
const extraArgsObj: Record<string, string | number | boolean> = {};
value.extra_args?.forEach(
(arg: { key: string; type: string; value: string }) => {
if (arg.type === 'number') {
extraArgsObj[arg.key] = Number(arg.value);
} else if (arg.type === 'boolean') {
extraArgsObj[arg.key] = arg.value === 'true';
} else {
extraArgsObj[arg.key] = arg.value;
}
},
);
const embeddingModel: EmbeddingModel = {
uuid: editMode ? initEmbeddingId || '' : UUID.generate(),
name: value.name,
description: '',
requester: value.model_provider,
requester_config: {
base_url: value.url,
timeout: 120,
},
extra_args: extraArgsObj,
api_keys: [value.api_key],
};
if (editMode) {
onSaveEdit(embeddingModel).then(() => {
form.reset();
});
} else {
onCreateEmbedding(embeddingModel).then(() => {
form.reset();
});
}
}
async function onCreateEmbedding(embeddingModel: EmbeddingModel) {
try {
await httpClient.createProviderEmbeddingModel(embeddingModel);
onFormSubmit();
toast.success(t('models.createSuccess'));
} catch (err) {
toast.error(t('models.createError') + (err as Error).message);
}
}
async function onSaveEdit(embeddingModel: EmbeddingModel) {
try {
await httpClient.updateProviderEmbeddingModel(
initEmbeddingId || '',
embeddingModel,
);
onFormSubmit();
toast.success(t('models.saveSuccess'));
} catch (err) {
toast.error(t('models.saveError') + (err as Error).message);
}
}
function deleteModel() {
if (initEmbeddingId) {
httpClient
.deleteProviderEmbeddingModel(initEmbeddingId)
.then(() => {
onEmbeddingDeleted();
toast.success(t('models.deleteSuccess'));
})
.catch((err) => {
toast.error(t('models.deleteError') + err.message);
});
}
}
function testEmbeddingModelInForm() {
setModelTesting(true);
const extraArgsObj: Record<string, string | number | boolean> = {};
form
.getValues('extra_args')
?.forEach((arg: { key: string; type: string; value: string }) => {
if (arg.type === 'number') {
extraArgsObj[arg.key] = Number(arg.value);
} else if (arg.type === 'boolean') {
extraArgsObj[arg.key] = arg.value === 'true';
} else {
extraArgsObj[arg.key] = arg.value;
}
});
httpClient
.testEmbeddingModel('_', {
uuid: '',
name: form.getValues('name'),
description: '',
requester: form.getValues('model_provider'),
requester_config: {
base_url: form.getValues('url'),
timeout: 120,
},
api_keys: [form.getValues('api_key')],
extra_args: extraArgsObj,
})
.then((res) => {
console.log(res);
toast.success(t('models.testSuccess'));
})
.catch(() => {
toast.error(t('models.testError'));
})
.finally(() => {
setModelTesting(false);
});
}
return (
<div>
<Dialog
open={showDeleteConfirmModal}
onOpenChange={setShowDeleteConfirmModal}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
</DialogHeader>
<DialogDescription>
{t('models.deleteConfirmation')}
</DialogDescription>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirmModal(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="destructive"
onClick={() => {
deleteModel();
setShowDeleteConfirmModal(false);
}}
>
{t('common.confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleFormSubmit)}
className="space-y-8"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.modelName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t('models.modelProviderDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="model_provider"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.modelProvider')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
field.onChange(value);
setCurrentModelProvider(value);
const index = requesterNameList.findIndex(
(item) => item.value === value,
);
if (index !== -1) {
form.setValue('url', requesterDefaultURLList[index]);
}
}}
value={field.value}
>
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('models.selectModelProvider')}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{requesterNameList.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.requestURL')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!['ollama-chat'].includes(currentModelProvider) && (
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('models.apiKey')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormItem>
<FormLabel>{t('models.extraParameters')}</FormLabel>
<div className="space-y-2">
{extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder={t('models.keyName')}
value={arg.key}
onChange={(e) =>
updateExtraArg(index, 'key', e.target.value)
}
/>
<Select
value={arg.type}
onValueChange={(value) =>
updateExtraArg(index, 'type', value)
}
>
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.type')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">
{t('models.string')}
</SelectItem>
<SelectItem value="number">
{t('models.number')}
</SelectItem>
<SelectItem value="boolean">
{t('models.boolean')}
</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t('models.value')}
value={arg.value}
onChange={(e) =>
updateExtraArg(index, 'value', e.target.value)
}
/>
<button
type="button"
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeExtraArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
</div>
))}
<Button type="button" variant="outline" onClick={addExtraArg}>
{t('models.addParameter')}
</Button>
</div>
<FormDescription>
{t('embedding.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>
</div>
<DialogFooter>
{editMode && (
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteConfirmModal(true)}
>
{t('common.delete')}
</Button>
)}
<Button type="submit">
{editMode ? t('common.save') : t('common.submit')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => testEmbeddingModelInForm()}
disabled={modelTesting}
>
{t('common.test')}
</Button>
<Button
type="button"
variant="outline"
onClick={() => onFormCancel()}
>
{t('common.cancel')}
</Button>
</DialogFooter>
</form>
</Form>
</div>
);
}

View File

@@ -6,12 +6,22 @@
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
padding: 1.2rem;
cursor: pointer;
transition: all 0.2s ease;
}
:global(.dark) .cardContainer {
background-color: #1f1f22;
box-shadow: 0;
}
.cardContainer:hover {
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
}
:global(.dark) .cardContainer:hover {
box-shadow: 0;
}
.iconBasicInfoContainer {
width: 100%;
height: 100%;
@@ -40,6 +50,11 @@
.basicInfoText {
font-size: 1.4rem;
font-weight: bold;
color: #1a1a1a;
}
:global(.dark) .basicInfoText {
color: #f0f0f0;
}
.providerContainer {
@@ -57,12 +72,20 @@
color: #626262;
}
:global(.dark) .providerIcon {
color: #a0a0a0;
}
.providerLabel {
font-size: 1.2rem;
font-weight: 600;
color: #626262;
}
:global(.dark) .providerLabel {
color: #a0a0a0;
}
.baseURLContainer {
display: flex;
flex-direction: row;
@@ -78,6 +101,10 @@
color: #626262;
}
:global(.dark) .baseURLIcon {
color: #a0a0a0;
}
.baseURLText {
font-size: 1rem;
width: 100%;
@@ -88,6 +115,10 @@
max-width: 100%;
}
:global(.dark) .baseURLText {
color: #a0a0a0;
}
.abilitiesContainer {
display: flex;
flex-direction: row;
@@ -108,18 +139,30 @@
background-color: #66baff80;
}
:global(.dark) .abilityBadge {
background-color: rgba(34, 136, 238, 0.3);
}
.abilityIcon {
width: 1rem;
height: 1rem;
color: #2288ee;
}
:global(.dark) .abilityIcon {
color: #66baff;
}
.abilityLabel {
font-size: 0.8rem;
font-weight: 400;
color: #2288ee;
}
:global(.dark) .abilityLabel {
color: #66baff;
}
.bigText {
white-space: nowrap;
overflow: hidden;

View File

@@ -1,6 +1,6 @@
import { ICreateLLMField } from '@/app/home/models/ICreateLLMField';
import { ICreateLLMField } from '@/app/home/models/component/ICreateLLMField';
import { useEffect, useState } from 'react';
import { IChooseRequesterEntity } from '@/app/home/models/component/llm-form/ChooseRequesterEntity';
import { IChooseRequesterEntity } from '@/app/home/models/component/ChooseRequesterEntity';
import { httpClient } from '@/app/infra/http/HttpClient';
import { LLMModel } from '@/app/infra/entities/api';
import { UUID } from 'uuidjs';
@@ -197,7 +197,7 @@ export default function LLMForm({
};
async function initLLMModelFormComponent() {
const requesterNameList = await httpClient.getProviderRequesters();
const requesterNameList = await httpClient.getProviderRequesters('llm');
setRequesterNameList(
requesterNameList.requesters.map((item) => {
return {
@@ -312,6 +312,18 @@ export default function LLMForm({
function testLLMModelInForm() {
setModelTesting(true);
const extraArgsObj: Record<string, string | number | boolean> = {};
form
.getValues('extra_args')
?.forEach((arg: { key: string; type: string; value: string }) => {
if (arg.type === 'number') {
extraArgsObj[arg.key] = Number(arg.value);
} else if (arg.type === 'boolean') {
extraArgsObj[arg.key] = arg.value === 'true';
} else {
extraArgsObj[arg.key] = arg.value;
}
});
httpClient
.testLLMModel('_', {
uuid: '',
@@ -324,7 +336,7 @@ export default function LLMForm({
},
api_keys: [form.getValues('api_key')],
abilities: form.getValues('abilities'),
extra_args: form.getValues('extra_args'),
extra_args: extraArgsObj,
})
.then((res) => {
console.log(res);
@@ -420,7 +432,7 @@ export default function LLMForm({
}}
value={field.value}
>
<SelectTrigger className="w-[180px]">
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('models.selectModelProvider')}
/>
@@ -553,7 +565,7 @@ export default function LLMForm({
updateExtraArg(index, 'type', value)
}
>
<SelectTrigger className="w-[120px]">
<SelectTrigger className="w-[120px] bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.type')} />
</SelectTrigger>
<SelectContent>
@@ -596,7 +608,7 @@ export default function LLMForm({
</Button>
</div>
<FormDescription>
{t('models.extraParametersDescription')}
{t('llm.extraParametersDescription')}
</FormDescription>
<FormMessage />
</FormItem>

View File

@@ -8,6 +8,7 @@ import LLMForm from '@/app/home/models/component/llm-form/LLMForm';
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { LLMModel } from '@/app/infra/entities/api';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
@@ -17,6 +18,9 @@ import {
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { EmbeddingCardVO } from '@/app/home/models/component/embedding-card/EmbeddingCardVO';
import EmbeddingCard from '@/app/home/models/component/embedding-card/EmbeddingCard';
import EmbeddingForm from '@/app/home/models/component/embedding-form/EmbeddingForm';
export default function LLMConfigPage() {
const { t } = useTranslation();
@@ -24,13 +28,21 @@ export default function LLMConfigPage() {
const [modalOpen, setModalOpen] = useState<boolean>(false);
const [isEditForm, setIsEditForm] = useState(false);
const [nowSelectedLLM, setNowSelectedLLM] = useState<LLMCardVO | null>(null);
const [embeddingCardList, setEmbeddingCardList] = useState<EmbeddingCardVO[]>(
[],
);
const [embeddingModalOpen, setEmbeddingModalOpen] = useState<boolean>(false);
const [isEditEmbeddingForm, setIsEditEmbeddingForm] = useState(false);
const [nowSelectedEmbedding, setNowSelectedEmbedding] =
useState<EmbeddingCardVO | null>(null);
useEffect(() => {
getLLMModelList();
getEmbeddingModelList();
}, []);
async function getLLMModelList() {
const requesterNameListResp = await httpClient.getProviderRequesters();
const requesterNameListResp = await httpClient.getProviderRequesters('llm');
const requesterNameList = requesterNameListResp.requesters.map((item) => {
return {
label: extractI18nObject(item.label),
@@ -74,6 +86,55 @@ export default function LLMConfigPage() {
setNowSelectedLLM(null);
setModalOpen(true);
}
function selectEmbedding(cardVO: EmbeddingCardVO) {
setIsEditEmbeddingForm(true);
setNowSelectedEmbedding(cardVO);
setEmbeddingModalOpen(true);
}
function handleCreateEmbeddingModelClick() {
setIsEditEmbeddingForm(false);
setNowSelectedEmbedding(null);
setEmbeddingModalOpen(true);
}
async function getEmbeddingModelList() {
const requesterNameListResp =
await httpClient.getProviderRequesters('text-embedding');
const requesterNameList = requesterNameListResp.requesters.map((item) => {
return {
label: extractI18nObject(item.label),
value: item.name,
};
});
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
const embeddingModelList: EmbeddingCardVO[] = resp.models.map(
(model: {
uuid: string;
requester: string;
name: string;
requester_config?: { base_url?: string };
}) => {
return new EmbeddingCardVO({
id: model.uuid,
iconURL: httpClient.getProviderRequesterIconURL(model.requester),
name: model.name,
providerLabel:
requesterNameList.find((item) => item.value === model.requester)
?.label || model.requester.substring(0, 10),
baseURL: model.requester_config?.base_url || '',
});
},
);
setEmbeddingCardList(embeddingModelList);
})
.catch((err) => {
console.error('get Embedding model list error', err);
toast.error(t('embedding.getModelListError') + err.message);
});
}
return (
<div>
@@ -101,26 +162,110 @@ export default function LLMConfigPage() {
/>
</DialogContent>
</Dialog>
<div className={`${styles.modelListContainer}`}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateModelClick}
/>
{cardList.map((cardVO) => {
return (
<div
key={cardVO.id}
onClick={() => {
selectLLM(cardVO);
}}
>
<LLMCard cardVO={cardVO}></LLMCard>
<Dialog open={embeddingModalOpen} onOpenChange={setEmbeddingModalOpen}>
<DialogContent className="w-[700px] p-6">
<DialogHeader>
<DialogTitle>
{isEditEmbeddingForm
? t('embedding.editModel')
: t('embedding.createModel')}
</DialogTitle>
</DialogHeader>
<EmbeddingForm
editMode={isEditEmbeddingForm}
initEmbeddingId={nowSelectedEmbedding?.id}
onFormSubmit={() => {
setEmbeddingModalOpen(false);
getEmbeddingModelList();
}}
onFormCancel={() => {
setEmbeddingModalOpen(false);
}}
onEmbeddingDeleted={() => {
setEmbeddingModalOpen(false);
getEmbeddingModelList();
}}
/>
</DialogContent>
</Dialog>
<Tabs defaultValue="llm" className="w-full">
<div className="flex flex-row gap-0 mb-4">
<div className="flex flex-row justify-between items-center px-[0.8rem]">
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger value="llm" className="px-6 py-4 cursor-pointer">
{t('llm.llmModels')}
</TabsTrigger>
<TabsTrigger
value="embedding"
className="px-6 py-4 cursor-pointer"
>
{t('embedding.embeddingModels')}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="llm">
<div className="flex flex-row justify-between items-center px-[0.4rem] h-full">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('llm.description')}
</p>
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="embedding">
<div className="flex flex-row justify-between items-center px-[0.4rem] h-full">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('embedding.description')}
</p>
</div>
</TabsContent>
</div>
<TabsContent value="llm">
<div className={`${styles.modelListContainer}`}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateModelClick}
/>
{cardList.map((cardVO) => {
return (
<div
key={cardVO.id}
onClick={() => {
selectLLM(cardVO);
}}
>
<LLMCard cardVO={cardVO}></LLMCard>
</div>
);
})}
</div>
</TabsContent>
<TabsContent value="embedding">
<div className={`${styles.modelListContainer}`}>
<CreateCardComponent
width={'100%'}
height={'10rem'}
plusSize={'90px'}
onClick={handleCreateEmbeddingModelClick}
/>
{embeddingCardList.map((cardVO) => {
return (
<div
key={cardVO.id}
onClick={() => {
selectEmbedding(cardVO);
}}
>
<EmbeddingCard cardVO={cardVO}></EmbeddingCard>
</div>
);
})}
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -18,7 +18,6 @@ import {
} 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;
@@ -26,7 +25,6 @@ interface PipelineDialogProps {
pipelineId?: string;
isEditMode?: boolean;
isDefaultPipeline?: boolean;
initValues?: PipelineFormEntity;
onFinish: () => void;
onNewPipelineCreated?: (pipelineId: string) => void;
onDeletePipeline: () => void;
@@ -41,7 +39,6 @@ export default function PipelineDialog({
pipelineId: propPipelineId,
isEditMode = false,
isDefaultPipeline = false,
initValues,
onFinish,
onNewPipelineCreated,
onDeletePipeline,
@@ -119,7 +116,6 @@ export default function PipelineDialog({
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6">
<PipelineFormComponent
initValues={initValues}
isDefaultPipeline={isDefaultPipeline}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}
@@ -146,7 +142,7 @@ export default function PipelineDialog({
<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"
className="hidden md:flex h-full min-h-0 w-40 border-r bg-white dark:bg-black"
>
<SidebarContent>
<SidebarGroup>
@@ -184,7 +180,6 @@ export default function PipelineDialog({
>
{currentMode === 'config' && (
<PipelineFormComponent
initValues={initValues}
isDefaultPipeline={isDefaultPipeline}
onFinish={handleFinish}
onNewPipelineCreated={handleNewPipelineCreated}

View File

@@ -15,13 +15,13 @@ export default function AtBadge({
return (
<Badge
variant="secondary"
className="flex items-center gap-1 px-2 py-1 text-sm bg-blue-100 text-blue-600 hover:bg-blue-200"
className="flex items-center gap-1 px-2 py-1 text-sm bg-blue-100 dark:bg-blue-900/40 text-blue-600 dark:text-blue-300 hover:bg-blue-200 dark:hover:bg-blue-900/60"
>
@{targetName}
{!readonly && onRemove && (
<button
onClick={onRemove}
className="ml-1 hover:text-blue-800 focus:outline-none"
className="ml-1 hover:text-blue-800 dark:hover:text-blue-200 focus:outline-none"
>
<X className="h-3 w-3" />
</button>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { DialogContent } from '@/components/ui/dialog';
@@ -10,6 +10,7 @@ import { cn } from '@/lib/utils';
import { Message } from '@/app/infra/entities/message';
import { toast } from 'sonner';
import AtBadge from './AtBadge';
import { Switch } from '@/components/ui/switch';
interface MessageComponent {
type: 'At' | 'Plain';
@@ -36,17 +37,44 @@ export default function DebugDialog({
const [showAtPopover, setShowAtPopover] = useState(false);
const [hasAt, setHasAt] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [isStreaming, setIsStreaming] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const scrollToBottom = useCallback(() => {
// 使用setTimeout确保在DOM更新后执行滚动
setTimeout(() => {
const scrollArea = document.querySelector('.scroll-area') as HTMLElement;
if (scrollArea) {
scrollArea.scrollTo({
top: scrollArea.scrollHeight,
behavior: 'smooth',
});
}
// 同时确保messagesEndRef也滚动到视图
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 0);
}, []);
const loadMessages = useCallback(
async (pipelineId: string) => {
try {
const response = await httpClient.getWebChatHistoryMessages(
pipelineId,
sessionType,
);
setMessages(response.messages);
} catch (error) {
console.error('Failed to load messages:', error);
}
},
[sessionType],
);
// 在useEffect中监听messages变化时滚动
useEffect(() => {
scrollToBottom();
}, [messages]);
}, [messages, scrollToBottom]);
useEffect(() => {
if (open) {
@@ -59,7 +87,7 @@ export default function DebugDialog({
if (open) {
loadMessages(selectedPipelineId);
}
}, [sessionType, selectedPipelineId]);
}, [sessionType, selectedPipelineId, open, loadMessages]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -84,18 +112,6 @@ export default function DebugDialog({
}
}, [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') {
@@ -165,19 +181,87 @@ export default function DebugDialog({
timestamp: new Date().toISOString(),
message_chain: messageChain,
};
// 根据isStreaming状态决定使用哪种传输方式
if (isStreaming) {
// streaming
// 创建初始bot消息
const placeholderRandomId = Math.floor(Math.random() * 1000000);
const botMessagePlaceholder: Message = {
id: placeholderRandomId,
role: 'assistant',
content: 'Generating...',
timestamp: new Date().toISOString(),
message_chain: [{ type: 'Plain', text: 'Generating...' }],
};
setMessages((prevMessages) => [...prevMessages, userMessage]);
setInputValue('');
setHasAt(false);
// 添加用户消息和初始bot消息到状态
const response = await httpClient.sendWebChatMessage(
sessionType,
messageChain,
selectedPipelineId,
120000,
);
setMessages((prevMessages) => [
...prevMessages,
userMessage,
botMessagePlaceholder,
]);
setInputValue('');
setHasAt(false);
try {
await httpClient.sendStreamingWebChatMessage(
sessionType,
messageChain,
selectedPipelineId,
(data) => {
// 处理流式响应数据
console.log('data', data);
if (data.message) {
// 更新完整内容
setMessages((prevMessages) => [...prevMessages, response.message]);
setMessages((prevMessages) => {
const updatedMessages = [...prevMessages];
const botMessageIndex = updatedMessages.findIndex(
(message) => message.id === placeholderRandomId,
);
if (botMessageIndex !== -1) {
updatedMessages[botMessageIndex] = {
...updatedMessages[botMessageIndex],
content: data.message.content,
message_chain: [
{ type: 'Plain', text: data.message.content },
],
};
}
return updatedMessages;
});
}
},
() => {},
(error) => {
// 处理错误
console.error('Streaming error:', error);
if (sessionType === 'person') {
toast.error(t('pipelines.debugDialog.sendFailed'));
}
},
);
} catch (error) {
console.error('Failed to send streaming message:', error);
if (sessionType === 'person') {
toast.error(t('pipelines.debugDialog.sendFailed'));
}
}
} else {
// non-streaming
setMessages((prevMessages) => [...prevMessages, userMessage]);
setInputValue('');
setHasAt(false);
const response = await httpClient.sendWebChatMessage(
sessionType,
messageChain,
selectedPipelineId,
180000,
);
setMessages((prevMessages) => [...prevMessages, response.message]);
}
} catch (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any
@@ -218,14 +302,14 @@ export default function DebugDialog({
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">
<div className="w-14 bg-white dark:bg-black 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'
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
} border-0 shadow-none`}
onClick={() => setSessionType('person')}
>
@@ -244,7 +328,7 @@ export default function DebugDialog({
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'
: 'bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
} border-0 shadow-none`}
onClick={() => setSessionType('group')}
>
@@ -261,7 +345,7 @@ export default function DebugDialog({
</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">
<ScrollArea className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black">
<div className="space-y-6">
{messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg">
@@ -281,7 +365,7 @@ export default function DebugDialog({
'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',
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
)}
>
{renderMessageContent(message)}
@@ -290,7 +374,7 @@ export default function DebugDialog({
'text-xs mt-2',
message.role === 'user'
? 'text-white/70'
: 'text-gray-500',
: 'text-gray-500 dark:text-gray-400',
)}
>
{message.role === 'user'
@@ -305,7 +389,13 @@ export default function DebugDialog({
</div>
</ScrollArea>
<div className="p-4 pb-0 bg-white flex gap-2">
<div className="p-4 pb-0 bg-white dark:bg-black flex gap-2">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">
{t('pipelines.debugDialog.streaming')}
</span>
<Switch checked={isStreaming} onCheckedChange={setIsStreaming} />
</div>
<div className="flex-1 flex items-center gap-2">
{hasAt && (
<AtBadge targetName="webchatbot" onRemove={handleAtRemove} />
@@ -322,23 +412,25 @@ export default function DebugDialog({
? 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"
className="flex-1 rounded-md px-3 py-2 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 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"
className="absolute bottom-full left-0 mb-2 w-auto rounded-md border bg-white dark:bg-gray-800 dark:border-gray-600 shadow-lg"
>
<div
className={cn(
'flex items-center gap-2 px-4 py-1.5 rounded cursor-pointer',
isHovering ? 'bg-gray-100' : 'bg-white',
isHovering
? 'bg-gray-100 dark:bg-gray-700'
: 'bg-white dark:bg-gray-800',
)}
onClick={handleAtSelect}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<span>
<span className="text-gray-800 dark:text-gray-200">
@webchatbot - {t('pipelines.debugDialog.atTips')}
</span>
</div>
@@ -369,7 +461,7 @@ export default function DebugDialog({
// 原有的Dialog包装
return (
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white">
<DialogContent className="!max-w-[70vw] max-w-6xl h-[70vh] p-6 flex flex-col rounded-2xl shadow-2xl bg-white dark:bg-black">
{renderContent()}
</DialogContent>
);

View File

@@ -10,12 +10,22 @@
flex-direction: row;
justify-content: space-between;
gap: 0.5rem;
transition: all 0.2s ease;
}
:global(.dark) .cardContainer {
background-color: #1f1f22;
box-shadow: 0;
}
.cardContainer:hover {
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
}
:global(.dark) .cardContainer:hover {
box-shadow: 0;
}
.basicInfoContainer {
width: 100%;
height: 100%;
@@ -35,6 +45,11 @@
.basicInfoNameText {
font-size: 1.4rem;
font-weight: 500;
color: #1a1a1a;
}
:global(.dark) .basicInfoNameText {
color: #f0f0f0;
}
.basicInfoDescriptionText {
@@ -48,6 +63,10 @@
color: #b1b1b1;
}
:global(.dark) .basicInfoDescriptionText {
color: #888888;
}
.basicInfoLastUpdatedTimeContainer {
display: flex;
flex-direction: row;
@@ -58,11 +77,21 @@
.basicInfoUpdateTimeIcon {
width: 1.2rem;
height: 1.2rem;
color: #626262;
}
:global(.dark) .basicInfoUpdateTimeIcon {
color: #a0a0a0;
}
.basicInfoUpdateTimeText {
font-size: 1rem;
font-weight: 400;
color: #626262;
}
:global(.dark) .basicInfoUpdateTimeText {
color: #a0a0a0;
}
.operationContainer {
@@ -86,12 +115,20 @@
color: #ffcd27;
}
:global(.dark) .operationDefaultBadgeIcon {
color: #fbbf24;
}
.operationDefaultBadgeText {
font-size: 1rem;
font-weight: 400;
color: #ffcd27;
}
:global(.dark) .operationDefaultBadgeText {
color: #fbbf24;
}
.bigText {
white-space: nowrap;
overflow: hidden;

View File

@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Pipeline } from '@/app/infra/entities/api';
import { GetPipelineResponseData, Pipeline } from '@/app/infra/entities/api';
import {
PipelineFormEntity,
PipelineConfigTab,
PipelineConfigStage,
} from '@/app/infra/entities/pipeline';
@@ -34,7 +33,6 @@ import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider';
export default function PipelineFormComponent({
initValues,
isDefaultPipeline,
onFinish,
onNewPipelineCreated,
@@ -49,8 +47,6 @@ export default function PipelineFormComponent({
isEditMode: boolean;
disableForm: boolean;
showButtons?: boolean;
// 这里的写法很不安全不规范,未来流水线需要重新整理
initValues?: PipelineFormEntity;
onFinish: () => void;
onNewPipelineCreated: (pipelineId: string) => void;
onDeletePipeline: () => void;
@@ -132,13 +128,26 @@ export default function PipelineFormComponent({
}
}
});
if (isEditMode) {
httpClient
.getPipeline(pipelineId || '')
.then((resp: GetPipelineResponseData) => {
form.reset({
basic: {
name: resp.pipeline.name,
description: resp.pipeline.description,
},
ai: resp.pipeline.config.ai,
trigger: resp.pipeline.config.trigger,
safety: resp.pipeline.config.safety,
output: resp.pipeline.config.output,
});
});
}
}, []);
useEffect(() => {
if (initValues) {
form.reset(initValues);
}
if (!isEditMode) {
form.reset({
basic: {
@@ -147,7 +156,7 @@ export default function PipelineFormComponent({
},
});
}
}, [initValues, form, isEditMode]);
}, [form, isEditMode]);
function handleFormSubmit(values: FormValues) {
console.log('handleFormSubmit', values);
@@ -339,7 +348,7 @@ export default function PipelineFormComponent({
return (
<>
<div className="!max-w-[70vw] max-w-6xl h-full p-0 flex flex-col bg-white">
<div className="!max-w-[70vw] max-w-6xl h-full p-0 flex flex-col bg-white dark:bg-black">
<Form {...form}>
<form
id="pipeline-form"
@@ -453,7 +462,7 @@ export default function PipelineFormComponent({
</form>
{/* 按钮栏移到 Tabs 外部,始终固定底部 */}
{showButtons && (
<div className="flex justify-end gap-2 pt-4 border-t mb-0 bg-white sticky bottom-0 z-10">
<div className="flex justify-end gap-2 pt-4 border-t mb-0 bg-white dark:bg-black sticky bottom-0 z-10">
{isEditMode && !isDefaultPipeline && (
<Button
type="button"

View File

@@ -4,11 +4,17 @@ import CreateCardComponent from '@/app/infra/basic-component/create-card-compone
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 { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import PipelineDialog from './PipelineDetailDialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
export default function PluginConfigPage() {
const { t } = useTranslation();
@@ -16,24 +22,22 @@ export default function PluginConfigPage() {
const [isEditForm, setIsEditForm] = useState(false);
const [pipelineList, setPipelineList] = useState<PipelineCardVO[]>([]);
const [selectedPipelineId, setSelectedPipelineId] = useState('');
const [selectedPipelineFormValue, setSelectedPipelineFormValue] =
useState<PipelineFormEntity>({
basic: {},
ai: {},
trigger: {},
safety: {},
output: {},
});
const [selectedPipelineIsDefault, setSelectedPipelineIsDefault] =
useState(false);
const [sortByValue, setSortByValue] = useState<string>('created_at');
const [sortOrderValue, setSortOrderValue] = useState<string>('DESC');
useEffect(() => {
getPipelines();
}, []);
function getPipelines() {
function getPipelines(
sortBy: string = sortByValue,
sortOrder: string = sortOrderValue,
) {
httpClient
.getPipelines()
.getPipelines(sortBy, sortOrder)
.then((value) => {
const currentTime = new Date();
const pipelineList = value.pipelines.map((pipeline) => {
@@ -69,43 +73,27 @@ export default function PluginConfigPage() {
});
}
function getSelectedPipelineForm(id?: string) {
httpClient.getPipeline(id ?? selectedPipelineId).then((value) => {
setSelectedPipelineFormValue({
ai: value.pipeline.config.ai,
basic: {
description: value.pipeline.description,
name: value.pipeline.name,
},
output: value.pipeline.config.output,
safety: value.pipeline.config.safety,
trigger: value.pipeline.config.trigger,
});
setSelectedPipelineIsDefault(value.pipeline.is_default ?? false);
});
}
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);
};
function handleSortChange(value: string) {
const [newSortBy, newSortOrder] = value.split(',').map((s) => s.trim());
setSortByValue(newSortBy);
setSortOrderValue(newSortOrder);
getPipelines(newSortBy, newSortOrder);
}
return (
<div className={styles.configPageContainer}>
<PipelineDialog
@@ -114,7 +102,6 @@ export default function PluginConfigPage() {
pipelineId={selectedPipelineId || undefined}
isEditMode={isEditForm}
isDefaultPipeline={selectedPipelineIsDefault}
initValues={selectedPipelineFormValue}
onFinish={() => {
getPipelines();
}}
@@ -123,7 +110,6 @@ export default function PluginConfigPage() {
setSelectedPipelineId(pipelineId);
setIsEditForm(true);
setDialogOpen(true);
getSelectedPipelineForm(pipelineId);
}}
onDeletePipeline={() => {
getPipelines();
@@ -134,6 +120,36 @@ export default function PluginConfigPage() {
}}
/>
<div className="flex flex-row justify-between items-center mb-4 px-[0.8rem]">
<Select
value={`${sortByValue},${sortOrderValue}`}
onValueChange={handleSortChange}
>
<SelectTrigger className="w-[180px] cursor-pointer bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('pipelines.sortBy')} />
</SelectTrigger>
<SelectContent className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectItem
value="created_at,DESC"
className="text-gray-900 dark:text-gray-100"
>
{t('pipelines.newestCreated')}
</SelectItem>
<SelectItem
value="updated_at,DESC"
className="text-gray-900 dark:text-gray-100"
>
{t('pipelines.recentlyEdited')}
</SelectItem>
<SelectItem
value="updated_at,ASC"
className="text-gray-900 dark:text-gray-100"
>
{t('pipelines.earliestEdited')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className={styles.pipelineListContainer}>
<CreateCardComponent
width={'100%'}

View File

@@ -209,7 +209,7 @@ export default function PluginConfigPage() {
/>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<div className="flex flex-row justify-between items-center px-[0.8rem]">
<TabsList className="shadow-md py-5 bg-[#f0f0f0]">
<TabsList className="shadow-md py-5 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger value="installed" className="px-6 py-4 cursor-pointer">
{t('plugins.installed')}
</TabsTrigger>
@@ -273,7 +273,7 @@ export default function PluginConfigPage() {
</Tabs>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="w-[500px] p-6">
<DialogContent className="w-[500px] p-6 bg-white dark:bg-[#1a1a1e]">
<DialogHeader>
<DialogTitle className="flex items-center gap-4">
<Download className="size-6" />

View File

@@ -31,7 +31,7 @@ export default function PluginMarketCardComponent({
</div>
</div>
<div className="text-[0.8rem] text-[#666] line-clamp-2">
<div className="text-[0.8rem] text-[#666] dark:text-[#888888] line-clamp-2">
{cardVO.description}
</div>
</div>

View File

@@ -7,13 +7,27 @@
align-items: center;
justify-content: space-evenly;
cursor: pointer;
transition: all 0.2s ease;
}
:global(.dark) .cardContainer {
background-color: #1f1f22;
box-shadow: 0;
}
.cardContainer:hover {
box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.05);
}
:global(.dark) .cardContainer:hover {
box-shadow: 0;
}
.createCardContainer {
font-size: 90px;
color: #acacac;
}
:global(.dark) .createCardContainer {
color: #666666;
}

View File

@@ -56,6 +56,38 @@ export interface LLMModel {
// updated_at: string;
}
export interface KnowledgeBase {
uuid?: string;
name: string;
description: string;
embedding_model_uuid: string;
created_at?: string;
top_k: number;
}
export interface ApiRespProviderEmbeddingModels {
models: EmbeddingModel[];
}
export interface ApiRespProviderEmbeddingModel {
model: EmbeddingModel;
}
export interface EmbeddingModel {
name: string;
description: string;
uuid: string;
requester: string;
requester_config: {
base_url: string;
timeout: number;
};
extra_args?: object;
api_keys: string[];
// created_at: string;
// updated_at: string;
}
export interface ApiRespPipelines {
pipelines: Pipeline[];
}
@@ -111,6 +143,34 @@ export interface Bot {
updated_at?: string;
}
export interface ApiRespKnowledgeBases {
bases: KnowledgeBase[];
}
export interface ApiRespKnowledgeBase {
base: KnowledgeBase;
}
export interface KnowledgeBase {
uuid?: string;
name: string;
description: string;
embedding_model_uuid: string;
top_k: number;
created_at?: string;
updated_at?: string;
}
export interface ApiRespKnowledgeBaseFiles {
files: KnowledgeBaseFile[];
}
export interface KnowledgeBaseFile {
uuid: string;
file_name: string;
status: string;
}
// plugins
export interface ApiRespPlugins {
plugins: Plugin[];
@@ -226,3 +286,18 @@ export interface ApiRespWebChatMessage {
export interface ApiRespWebChatMessages {
messages: Message[];
}
export interface RetrieveResult {
id: string;
metadata: {
file_id: string;
text: string;
uuid: string;
[key: string]: unknown;
};
distance: number;
}
export interface ApiRespKnowledgeBaseRetrieve {
results: RetrieveResult[];
}

View File

@@ -21,6 +21,7 @@ export enum DynamicFormItemType {
LLM_MODEL_SELECTOR = 'llm-model-selector',
PROMPT_EDITOR = 'prompt-editor',
UNKNOWN = 'unknown',
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
}
export interface IDynamicFormItemOption {

View File

@@ -24,6 +24,14 @@ import {
AsyncTask,
ApiRespWebChatMessage,
ApiRespWebChatMessages,
ApiRespKnowledgeBases,
ApiRespKnowledgeBase,
KnowledgeBase,
ApiRespKnowledgeBaseFiles,
ApiRespKnowledgeBaseRetrieve,
ApiRespProviderEmbeddingModels,
ApiRespProviderEmbeddingModel,
EmbeddingModel,
} from '@/app/infra/entities/api';
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
@@ -38,8 +46,10 @@ export class BackendClient extends BaseHttpClient {
}
// ============ Provider API ============
public getProviderRequesters(): Promise<ApiRespProviderRequesters> {
return this.get('/api/v1/provider/requesters');
public getProviderRequesters(
model_type: string,
): Promise<ApiRespProviderRequesters> {
return this.get('/api/v1/provider/requesters', { type: model_type });
}
public getProviderRequester(name: string): Promise<ApiRespProviderRequester> {
@@ -87,6 +97,39 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/provider/models/llm/${uuid}/test`, model);
}
// ============ Provider Model Embedding ============
public getProviderEmbeddingModels(): Promise<ApiRespProviderEmbeddingModels> {
return this.get('/api/v1/provider/models/embedding');
}
public getProviderEmbeddingModel(
uuid: string,
): Promise<ApiRespProviderEmbeddingModel> {
return this.get(`/api/v1/provider/models/embedding/${uuid}`);
}
public createProviderEmbeddingModel(model: EmbeddingModel): Promise<object> {
return this.post('/api/v1/provider/models/embedding', model);
}
public deleteProviderEmbeddingModel(uuid: string): Promise<object> {
return this.delete(`/api/v1/provider/models/embedding/${uuid}`);
}
public updateProviderEmbeddingModel(
uuid: string,
model: EmbeddingModel,
): Promise<object> {
return this.put(`/api/v1/provider/models/embedding/${uuid}`, model);
}
public testEmbeddingModel(
uuid: string,
model: EmbeddingModel,
): Promise<object> {
return this.post(`/api/v1/provider/models/embedding/${uuid}/test`, model);
}
// ============ Pipeline API ============
public getGeneralPipelineMetadata(): Promise<GetPipelineMetadataResponseData> {
// as designed, this method will be deprecated, and only for developer to check the prefered config schema
@@ -115,6 +158,8 @@ export class BackendClient extends BaseHttpClient {
return this.delete(`/api/v1/pipelines/${uuid}`);
}
// ============ Debug WebChat API ============
// ============ Debug WebChat API ============
public sendWebChatMessage(
sessionType: string,
@@ -134,6 +179,99 @@ export class BackendClient extends BaseHttpClient {
);
}
public async sendStreamingWebChatMessage(
sessionType: string,
messageChain: object[],
pipelineId: string,
onMessage: (data: ApiRespWebChatMessage) => void,
onComplete: () => void,
onError: (error: Error) => void,
): Promise<void> {
try {
// 构造完整的URL处理相对路径的情况
let url = `${this.baseURL}/api/v1/pipelines/${pipelineId}/chat/send`;
if (this.baseURL === '/') {
// 获取用户访问的完整URL
const baseURL = window.location.origin;
url = `${baseURL}/api/v1/pipelines/${pipelineId}/chat/send`;
}
// 使用fetch发送流式请求因为axios在浏览器环境中不直接支持流式响应
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.getSessionSync()}`,
},
body: JSON.stringify({
session_type: sessionType,
message: messageChain,
is_stream: true,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error('ReadableStream not supported');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 读取流式响应
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
onComplete();
break;
}
// 解码数据
buffer += decoder.decode(value, { stream: true });
// 处理完整的JSON对象
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data:')) {
try {
const data = JSON.parse(line.slice(5));
if (data.type === 'end') {
// 流传输结束
reader.cancel();
onComplete();
return;
}
if (data.type === 'start') {
console.log(data.type);
}
if (data.message) {
// 处理消息数据
onMessage(data);
}
} catch (error) {
console.error('Error parsing streaming data:', error);
}
}
}
}
} finally {
reader.releaseLock();
}
} catch (error) {
onError(error as Error);
}
}
public getWebChatHistoryMessages(
pipelineId: string,
sessionType: string,
@@ -201,6 +339,74 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/platform/bots/${botId}/logs`, request);
}
// ============ File management API ============
public uploadDocumentFile(file: File): Promise<{ file_id: string }> {
const formData = new FormData();
formData.append('file', file);
return this.request<{ file_id: string }>({
method: 'post',
url: '/api/v1/files/documents',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
// ============ Knowledge Base API ============
public getKnowledgeBases(): Promise<ApiRespKnowledgeBases> {
return this.get('/api/v1/knowledge/bases');
}
public getKnowledgeBase(uuid: string): Promise<ApiRespKnowledgeBase> {
return this.get(`/api/v1/knowledge/bases/${uuid}`);
}
public createKnowledgeBase(base: KnowledgeBase): Promise<{ uuid: string }> {
return this.post('/api/v1/knowledge/bases', base);
}
public updateKnowledgeBase(
uuid: string,
base: KnowledgeBase,
): Promise<{ uuid: string }> {
return this.put(`/api/v1/knowledge/bases/${uuid}`, base);
}
public uploadKnowledgeBaseFile(
uuid: string,
file_id: string,
): Promise<object> {
return this.post(`/api/v1/knowledge/bases/${uuid}/files`, {
file_id,
});
}
public getKnowledgeBaseFiles(
uuid: string,
): Promise<ApiRespKnowledgeBaseFiles> {
return this.get(`/api/v1/knowledge/bases/${uuid}/files`);
}
public deleteKnowledgeBaseFile(
uuid: string,
file_id: string,
): Promise<object> {
return this.delete(`/api/v1/knowledge/bases/${uuid}/files/${file_id}`);
}
public deleteKnowledgeBase(uuid: string): Promise<object> {
return this.delete(`/api/v1/knowledge/bases/${uuid}`);
}
public retrieveKnowledgeBase(
uuid: string,
query: string,
): Promise<ApiRespKnowledgeBaseRetrieve> {
return this.post(`/api/v1/knowledge/bases/${uuid}/retrieve`, { query });
}
// ============ Plugins API ============
public getPlugins(): Promise<ApiRespPlugins> {
return this.get('/api/v1/plugins');
@@ -292,4 +498,26 @@ export class BackendClient extends BaseHttpClient {
public checkUserToken(): Promise<ApiRespUserToken> {
return this.get('/api/v1/user/check-token');
}
public resetPassword(
user: string,
recoveryKey: string,
newPassword: string,
): Promise<{ user: string }> {
return this.post('/api/v1/user/reset-password', {
user,
recovery_key: recoveryKey,
new_password: newPassword,
});
}
public changePassword(
currentPassword: string,
newPassword: string,
): Promise<{ user: string }> {
return this.post('/api/v1/user/change-password', {
current_password: currentPassword,
new_password: newPassword,
});
}
}

View File

@@ -3,6 +3,7 @@ import 'react-photo-view/dist/react-photo-view.css';
import type { Metadata } from 'next';
import { Toaster } from '@/components/ui/sonner';
import I18nProvider from '@/i18n/I18nProvider';
import { ThemeProvider } from '@/components/providers/theme-provider';
export const metadata: Metadata = {
title: 'LangBot',
@@ -15,12 +16,14 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html>
<html lang="zh" suppressHydrationWarning>
<body className={``}>
<I18nProvider>
{children}
<Toaster />
</I18nProvider>
<ThemeProvider>
<I18nProvider>
{children}
<Toaster />
</I18nProvider>
</ThemeProvider>
</body>
</html>
);

View File

@@ -8,13 +8,7 @@ import {
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { LanguageSelector } from '@/components/ui/language-selector';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
@@ -26,14 +20,15 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useRouter } from 'next/navigation';
import { Mail, Lock, Globe } from 'lucide-react';
import { Mail, Lock } from 'lucide-react';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
import Link from 'next/link';
import { ThemeToggle } from '@/components/ui/theme-toggle';
const formSchema = (t: (key: string) => string) =>
z.object({
@@ -44,7 +39,6 @@ const formSchema = (t: (key: string) => string) =>
export default function Login() {
const router = useRouter();
const { t } = useTranslation();
const [currentLanguage, setCurrentLanguage] = useState<string>(i18n.language);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)),
@@ -55,52 +49,10 @@ export default function Login() {
});
useEffect(() => {
judgeLanguage();
getIsInitialized();
checkIfAlreadyLoggedIn();
}, []);
const judgeLanguage = () => {
if (i18n.language === 'zh-CN' || i18n.language === 'zh-Hans') {
setCurrentLanguage('zh-Hans');
localStorage.setItem('langbot_language', 'zh-Hans');
} else if (i18n.language === 'ja' || i18n.language === 'ja-JP') {
setCurrentLanguage('ja-JP');
localStorage.setItem('langbot_language', 'ja-JP');
} else {
setCurrentLanguage('en-US');
localStorage.setItem('langbot_language', 'en-US');
}
// check if the language is already set
const lang = localStorage.getItem('langbot_language');
if (lang) {
i18n.changeLanguage(lang);
setCurrentLanguage(lang);
return;
} else {
const language = navigator.language;
if (language) {
let lang = 'zh-Hans';
if (language === 'zh-CN') {
lang = 'zh-Hans';
} else if (language === 'ja' || language === 'ja-JP') {
lang = 'ja-JP';
} else {
lang = 'en-US';
}
i18n.changeLanguage(lang);
setCurrentLanguage(lang);
localStorage.setItem('langbot_language', lang);
}
}
};
const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value);
setCurrentLanguage(value);
localStorage.setItem('langbot_language', value);
};
function getIsInitialized() {
httpClient
.checkIfInited()
@@ -149,24 +101,12 @@ export default function Login() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-[375px]">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
<CardHeader>
<div className="flex justify-end mb-6">
<Select
value={currentLanguage}
onValueChange={handleLanguageChange}
>
<SelectTrigger className="w-[140px]">
<Globe className="h-4 w-4 mr-2" />
<SelectValue placeholder={t('common.language')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh-Hans"></SelectItem>
<SelectItem value="en-US">English</SelectItem>
<SelectItem value="ja-JP"></SelectItem>
</SelectContent>
</Select>
<div className="flex justify-between items-center mb-6">
<ThemeToggle />
<LanguageSelector />
</div>
<img
src={langbotIcon.src}
@@ -209,7 +149,16 @@ export default function Login() {
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.password')}</FormLabel>
<div className="flex justify-between">
<FormLabel>{t('common.password')}</FormLabel>
<Link
href="/reset-password"
className="text-sm text-blue-500"
>
{t('common.forgotPassword')}
</Link>
</div>
<FormControl>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />

View File

@@ -8,13 +8,7 @@ import {
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { LanguageSelector } from '@/components/ui/language-selector';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
@@ -26,14 +20,14 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useRouter } from 'next/navigation';
import { Mail, Lock, Globe } from 'lucide-react';
import { Mail, Lock } from 'lucide-react';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
import { ThemeToggle } from '@/components/ui/theme-toggle';
const formSchema = (t: (key: string) => string) =>
z.object({
@@ -44,7 +38,6 @@ const formSchema = (t: (key: string) => string) =>
export default function Register() {
const router = useRouter();
const { t } = useTranslation();
const [currentLanguage, setCurrentLanguage] = useState<string>(i18n.language);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)),
@@ -55,53 +48,9 @@ export default function Register() {
});
useEffect(() => {
judgeLanguage();
getIsInitialized();
}, []);
const judgeLanguage = () => {
if (i18n.language === 'zh-CN' || i18n.language === 'zh-Hans') {
setCurrentLanguage('zh-Hans');
localStorage.setItem('langbot_language', 'zh-Hans');
} else if (i18n.language === 'ja' || i18n.language === 'ja-JP') {
setCurrentLanguage('ja-JP');
localStorage.setItem('langbot_language', 'ja-JP');
} else {
setCurrentLanguage('en-US');
localStorage.setItem('langbot_language', 'en-US');
}
// check if the language is already set
const lang = localStorage.getItem('langbot_language');
console.log('lang: ', lang);
if (lang) {
i18n.changeLanguage(lang);
setCurrentLanguage(lang);
} else {
const language = navigator.language;
if (language) {
let lang = 'zh-Hans';
if (language === 'zh-CN') {
lang = 'zh-Hans';
} else if (language === 'ja' || language === 'ja-JP') {
lang = 'ja-JP';
} else {
lang = 'en-US';
}
console.log('language: ', lang);
i18n.changeLanguage(lang);
setCurrentLanguage(lang);
localStorage.setItem('langbot_language', lang);
}
}
};
const handleLanguageChange = (value: string) => {
console.log('handleLanguageChange: ', value);
i18n.changeLanguage(value);
setCurrentLanguage(value);
localStorage.setItem('langbot_language', value);
};
function getIsInitialized() {
httpClient
.checkIfInited()
@@ -134,24 +83,12 @@ export default function Register() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-[375px]">
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
<CardHeader>
<div className="flex justify-end mb-6">
<Select
value={currentLanguage}
onValueChange={handleLanguageChange}
>
<SelectTrigger className="w-[140px]">
<Globe className="h-4 w-4 mr-2" />
<SelectValue placeholder={t('common.language')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh-Hans"></SelectItem>
<SelectItem value="en-US">English</SelectItem>
<SelectItem value="ja-JP"></SelectItem>
</SelectContent>
</Select>
<div className="flex justify-between items-center mb-6">
<ThemeToggle />
<LanguageSelector />
</div>
<img
src={langbotIcon.src}

View File

@@ -0,0 +1,15 @@
'use client';
import React from 'react';
export default function ResetPasswordLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="min-h-screen bg-background">
<main className="min-h-screen">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,207 @@
'use client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from '@/components/ui/input-otp';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from '@/components/ui/form';
import { useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useRouter } from 'next/navigation';
import { Mail, Lock, ArrowLeft } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import { ThemeToggle } from '@/components/ui/theme-toggle';
const REGEXP_ONLY_DIGITS_AND_CHARS = /^[0-9a-zA-Z]+$/;
const formSchema = (t: (key: string) => string) =>
z.object({
email: z.string().email(t('common.invalidEmail')),
recoveryKey: z.string().min(1, t('resetPassword.recoveryKeyRequired')),
newPassword: z.string().min(1, t('resetPassword.newPasswordRequired')),
});
export default function ResetPassword() {
const router = useRouter();
const { t } = useTranslation();
const [isResetting, setIsResetting] = useState(false);
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
resolver: zodResolver(formSchema(t)),
defaultValues: {
email: '',
recoveryKey: '',
newPassword: '',
},
});
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
handleResetPassword(values.email, values.recoveryKey, values.newPassword);
}
function handleResetPassword(
email: string,
recoveryKey: string,
newPassword: string,
) {
setIsResetting(true);
httpClient
.resetPassword(email, recoveryKey, newPassword)
.then((res) => {
console.log('reset password success: ', res);
toast.success(t('resetPassword.resetSuccess'));
router.push('/login');
})
.catch((err) => {
console.log('reset password error: ', err);
toast.error(t('resetPassword.resetFailed'));
})
.finally(() => {
setIsResetting(false);
});
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
<CardHeader>
<div className="flex justify-between items-center mb-6">
<Link
href="/login"
className="flex items-center text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
{t('resetPassword.backToLogin')}
</Link>
<ThemeToggle />
</div>
<CardTitle className="text-2xl text-center">
{t('resetPassword.title')}
</CardTitle>
<CardDescription className="text-center">
{t('resetPassword.description')}
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.email')}</FormLabel>
<FormControl>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder={t('common.enterEmail')}
className="pl-10"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="recoveryKey"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.recoveryKey')}</FormLabel>
<FormDescription>
{t('resetPassword.recoveryKeyDescription')}
</FormDescription>
<FormControl>
<InputOTP
maxLength={6}
value={field.value}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS.source}
onChange={(value) => {
// 将输入的值转换为大写
const upperValue = value.toUpperCase();
field.onChange(upperValue);
}}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('resetPassword.newPassword')}</FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
type="password"
placeholder={t('resetPassword.enterNewPassword')}
className="pl-10"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full mt-4 cursor-pointer"
disabled={isResetting}
>
{isResetting
? t('resetPassword.resetting')
: t('resetPassword.resetPassword')}
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { type ThemeProviderProps } from 'next-themes';
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
{...props}
>
{children}
</NextThemesProvider>
);
}

View File

@@ -18,7 +18,7 @@ const buttonVariants = cva(
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/100',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {

View File

@@ -0,0 +1,252 @@
'use client';
import * as React from 'react';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
'text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
'text-muted-foreground ml-auto text-xs tracking-widest',
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,77 @@
'use client';
import * as React from 'react';
import { OTPInput, OTPInputContext } from 'input-otp';
import { MinusIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName,
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="input-otp-group"
className={cn('flex items-center', className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
'data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]',
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,98 @@
'use client';
import { useState, useEffect } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Globe } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import i18n from '@/i18n';
interface LanguageSelectorProps {
className?: string;
triggerClassName?: string;
onOpenChange?: (open: boolean) => void;
}
export function LanguageSelector({
triggerClassName,
onOpenChange,
}: LanguageSelectorProps) {
const { t } = useTranslation();
const [currentLanguage, setCurrentLanguage] = useState<string>(i18n.language);
useEffect(() => {
initializeLanguage();
}, []);
const initializeLanguage = () => {
if (i18n.language === 'zh-CN' || i18n.language === 'zh-Hans') {
setCurrentLanguage('zh-Hans');
localStorage.setItem('langbot_language', 'zh-Hans');
} else if (i18n.language === 'zh-TW' || i18n.language === 'zh-Hant') {
setCurrentLanguage('zh-Hant');
localStorage.setItem('langbot_language', 'zh-Hant');
} else if (i18n.language === 'ja' || i18n.language === 'ja-JP') {
setCurrentLanguage('ja-JP');
localStorage.setItem('langbot_language', 'ja-JP');
} else {
setCurrentLanguage('en-US');
localStorage.setItem('langbot_language', 'en-US');
}
const savedLanguage = localStorage.getItem('langbot_language');
if (savedLanguage) {
i18n.changeLanguage(savedLanguage);
setCurrentLanguage(savedLanguage);
} else {
const browserLanguage = navigator.language;
if (browserLanguage) {
let detectedLanguage = 'zh-Hans';
if (browserLanguage === 'zh-CN') {
detectedLanguage = 'zh-Hans';
} else if (browserLanguage === 'zh-TW') {
detectedLanguage = 'zh-Hant';
} else if (browserLanguage === 'ja' || browserLanguage === 'ja-JP') {
detectedLanguage = 'ja-JP';
} else {
detectedLanguage = 'en-US';
}
i18n.changeLanguage(detectedLanguage);
setCurrentLanguage(detectedLanguage);
localStorage.setItem('langbot_language', detectedLanguage);
}
}
};
const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value);
setCurrentLanguage(value);
localStorage.setItem('langbot_language', value);
// 刷新页面以应用新的语言设置
window.location.reload();
};
return (
<Select
value={currentLanguage}
onValueChange={handleLanguageChange}
onOpenChange={onOpenChange}
>
<SelectTrigger className={triggerClassName || 'w-[140px]'}>
<Globe className="h-4 w-4 mr-2" />
<SelectValue placeholder={t('common.language')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh-Hans"></SelectItem>
<SelectItem value="zh-Hant"></SelectItem>
<SelectItem value="en-US">English</SelectItem>
<SelectItem value="ja-JP"></SelectItem>
</SelectContent>
</Select>
);
}

View File

@@ -76,7 +76,7 @@ function PaginationPrevious({
className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
{...props}
>
<ChevronLeftIcon className="text-black" />
<ChevronLeftIcon className="text-black dark:text-white" />
<span className="hidden sm:block"></span>
</PaginationLink>
);
@@ -94,7 +94,7 @@ function PaginationNext({
{...props}
>
<span className="hidden sm:block"></span>
<ChevronRightIcon className="text-black" />
<ChevronRightIcon className="text-black dark:text-white" />
</PaginationLink>
);
}

View File

@@ -0,0 +1,116 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,23 @@
'use client';
import * as React from 'react';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="outline"
size="icon"
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
className="h-9 w-9"
>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
</Button>
);
}

View File

@@ -6,6 +6,7 @@ import LanguageDetector from 'i18next-browser-languagedetector';
import enUS from './locales/en-US';
import zhHans from './locales/zh-Hans';
import zhHant from './locales/zh-Hant';
import jaJP from './locales/ja-JP';
i18n
@@ -19,6 +20,9 @@ i18n
'zh-Hans': {
translation: zhHans,
},
'zh-Hant': {
translation: zhHant,
},
'ja-JP': {
translation: jaJP,
},

View File

@@ -2,6 +2,8 @@ const enUS = {
common: {
login: 'Login',
logout: 'Logout',
accountOptions: 'Account',
account: 'Account',
email: 'Email',
password: 'Password',
welcome: 'Welcome back to LangBot 👋',
@@ -39,6 +41,23 @@ const enUS = {
addRound: 'Add Round',
copySuccess: 'Copy Successfully',
test: 'Test',
forgotPassword: 'Forgot Password?',
loading: 'Loading...',
theme: 'Theme',
changePassword: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
confirmNewPassword: 'Confirm New Password',
enterCurrentPassword: 'Enter current password',
enterNewPassword: 'Enter new password',
enterConfirmPassword: 'Confirm new password',
currentPasswordRequired: 'Current password is required',
newPasswordRequired: 'New password is required',
confirmPasswordRequired: 'Confirm password is required',
passwordsDoNotMatch: 'Passwords do not match',
changePasswordSuccess: 'Password changed successfully',
changePasswordFailed:
'Failed to change password, please check your current password',
},
notFound: {
title: 'Page not found',
@@ -85,14 +104,13 @@ const enUS = {
string: 'String',
number: 'Number',
boolean: 'Boolean',
extraParametersDescription:
'Will be attached to the request body, such as max_tokens, temperature, top_p, etc.',
selectModelProvider: 'Select Model Provider',
modelProviderDescription:
'Please fill in the model name provided by the supplier',
selectModel: 'Select Model',
testSuccess: 'Test successful',
testError: 'Test failed, please check your model configuration',
llmModels: 'LLM Models',
},
bots: {
title: 'Bots',
@@ -266,6 +284,10 @@ const enUS = {
today: 'Today',
updateTime: 'Updated ',
defaultBadge: 'Default',
sortBy: 'Sort by',
newestCreated: 'Newest Created',
recentlyEdited: 'Recently Edited',
earliestEdited: 'Earliest Edited',
basicInfo: 'Basic',
aiCapabilities: 'AI',
triggerConditions: 'Trigger',
@@ -300,8 +322,72 @@ const enUS = {
loadMessagesFailed: 'Failed to load messages',
loadPipelinesFailed: 'Failed to load pipelines',
atTips: 'Mention the bot',
streaming: 'Streaming',
},
},
knowledge: {
title: 'Knowledge',
createKnowledgeBase: 'Create Knowledge Base',
editKnowledgeBase: 'Edit Knowledge Base',
selectKnowledgeBase: 'Select Knowledge Base',
empty: 'Empty',
editDocument: 'Documents',
description: 'Configuring knowledge bases for improved LLM responses',
metadata: 'Metadata',
documents: 'Documents',
kbNameRequired: 'Knowledge base name cannot be empty',
kbDescriptionRequired: 'Knowledge base description cannot be empty',
embeddingModelUUIDRequired: 'Embedding model cannot be empty',
daysAgo: 'days ago',
today: 'Today',
kbName: 'Knowledge Base Name',
kbDescription: 'Knowledge Base Description',
topK: 'Top K',
topKRequired: 'Top K cannot be empty',
topKMax: 'Top K maximum value is 30',
topKdescription:
'Used to specify the number of relevant documents to retrieve, ranging from 1 to 30.',
defaultDescription: 'A knowledge base',
embeddingModelUUID: 'Embedding Model',
selectEmbeddingModel: 'Select Embedding Model',
embeddingModelDescription:
'Used to vectorize the text, you can configure it in the Models page',
updateTime: 'Updated ',
cannotChangeEmbeddingModel:
'Knowledge base created cannot be modified embedding model',
updateKnowledgeBaseSuccess: 'Knowledge base updated successfully',
updateKnowledgeBaseFailed: 'Knowledge base update failed',
documentsTab: {
name: 'Name',
status: 'Status',
noResults: 'No documents',
dragAndDrop: 'Drag and drop files here or click to upload',
uploading: 'Uploading...',
supportedFormats:
'Supports PDF, Word, TXT, Markdown, HTML, ZIP and other document formats',
uploadSuccess: 'File uploaded successfully!',
uploadError: 'File upload failed, please try again',
uploadingFile: 'Uploading file...',
actions: 'Actions',
delete: 'Delete File',
fileDeleteSuccess: 'File deleted successfully',
fileDeleteFailed: 'File deletion failed',
processing: 'Processing',
completed: 'Completed',
failed: 'Failed',
},
deleteKnowledgeBaseConfirmation:
'Are you sure you want to delete this knowledge base? All documents in this knowledge base will be deleted.',
retrieve: 'Retrieve Test',
retrieveTest: 'Retrieve Test',
query: 'Query',
queryPlaceholder: 'Enter query text...',
distance: 'Distance',
content: 'Content',
fileName: 'File Name',
noResults: 'No results',
retrieveError: 'Retrieve failed',
},
register: {
title: 'Initialize LangBot 👋',
description: 'This is your first time starting LangBot',
@@ -311,6 +397,40 @@ const enUS = {
initSuccess: 'Initialization successful, please login',
initFailed: 'Initialization failed: ',
},
resetPassword: {
title: 'Reset Password 🔐',
description:
'Enter your recovery key and new password to reset your account password',
recoveryKey: 'Recovery Key',
recoveryKeyDescription:
'Stored in `system.recovery_key` of config file `data/config.yaml`',
newPassword: 'New Password',
enterRecoveryKey: 'Enter recovery key',
enterNewPassword: 'Enter new password',
recoveryKeyRequired: 'Recovery key cannot be empty',
newPasswordRequired: 'New password cannot be empty',
resetPassword: 'Reset Password',
resetting: 'Resetting...',
resetSuccess: 'Password reset successfully, please login',
resetFailed:
'Password reset failed, please check your email and recovery key',
backToLogin: 'Back to Login',
},
embedding: {
description: 'Manage Embedding models for text vectorization',
createModel: 'Create Embedding Model',
editModel: 'Edit Embedding Model',
getModelListError: 'Failed to get Embedding model list: ',
embeddingModels: 'Embedding',
extraParametersDescription:
'Will be attached to the request body, such as encoding_format, dimensions, etc.',
},
llm: {
description: 'Manage LLM models for conversation generation',
llmModels: 'LLM',
extraParametersDescription:
'Will be attached to the request body, such as max_tokens, temperature, top_p, etc.',
},
};
export default enUS;

View File

@@ -2,6 +2,8 @@ const jaJP = {
common: {
login: 'ログイン',
logout: 'ログアウト',
accountOptions: 'アカウントオプション',
account: 'アカウント',
email: 'メールアドレス',
password: 'パスワード',
welcome: 'LangBot へおかえりなさい 👋',
@@ -40,6 +42,23 @@ const jaJP = {
addRound: 'ラウンドを追加',
copySuccess: 'コピーに成功しました',
test: 'テスト',
forgotPassword: 'パスワードを忘れた?',
loading: '読み込み中...',
theme: 'テーマ',
changePassword: 'パスワードを変更',
currentPassword: '現在のパスワード',
newPassword: '新しいパスワード',
confirmNewPassword: '新しいパスワードを確認',
enterCurrentPassword: '現在のパスワードを入力',
enterNewPassword: '新しいパスワードを入力',
enterConfirmPassword: '新しいパスワードを確認',
currentPasswordRequired: '現在のパスワードは必須です',
newPasswordRequired: '新しいパスワードは必須です',
confirmPasswordRequired: '新しいパスワードを確認してください',
passwordsDoNotMatch: '新しいパスワードが一致しません',
changePasswordSuccess: 'パスワードの変更に成功しました',
changePasswordFailed:
'パスワードの変更に失敗しました。現在のパスワードを確認してください',
},
notFound: {
title: 'ページが見つかりません',
@@ -267,6 +286,10 @@ const jaJP = {
today: '今日',
updateTime: '更新日時',
defaultBadge: 'デフォルト',
sortBy: '並び順',
newestCreated: '最新作成',
recentlyEdited: '最近編集',
earliestEdited: '最古編集',
basicInfo: '基本情報',
aiCapabilities: 'AI機能',
triggerConditions: 'トリガー条件',
@@ -302,8 +325,73 @@ const jaJP = {
loadMessagesFailed: 'メッセージの読み込みに失敗しました',
loadPipelinesFailed: 'パイプラインの読み込みに失敗しました',
atTips: 'ボットをメンション',
streaming: 'ストリーミング',
},
},
knowledge: {
title: '知識ベース',
createKnowledgeBase: '知識ベースを作成',
editKnowledgeBase: '知識ベースを編集',
selectKnowledgeBase: '知識ベースを選択',
empty: 'なし',
editDocument: 'ドキュメント',
description: 'LLMの回答品質向上のための知識ベースを設定します',
metadata: 'メタデータ',
documents: 'ドキュメント',
kbNameRequired: '知識ベース名は必須です',
kbDescriptionRequired: '知識ベースの説明は必須です',
embeddingModelUUIDRequired: '埋め込みモデルは必須です',
daysAgo: '日前',
today: '今日',
kbName: '知識ベース名',
kbDescription: '知識ベースの説明',
topK: 'Top K',
topKRequired: 'Top Kは必須です',
topKMax: 'Top Kの最大値は30です',
topKdescription:
'取得する関連性の高い上位K件の文書の数。130の範囲で設定できます',
defaultDescription: '知識ベース',
embeddingModelUUID: '埋め込みモデル',
selectEmbeddingModel: '埋め込みモデルを選択',
embeddingModelDescription:
'テキストのベクトル化に使用する埋め込みモデルを管理します',
updateTime: '更新日時',
cannotChangeEmbeddingModel:
'知識ベース作成後は埋め込みモデルを変更できません',
updateKnowledgeBaseSuccess: '知識ベースの更新に成功しました',
updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました',
documentsTab: {
name: '名前',
status: 'ステータス',
noResults: 'ドキュメントがありません',
dragAndDrop:
'ファイルをここにドラッグ&ドロップするか、クリックしてアップロードしてください',
uploading: 'アップロード中...',
supportedFormats:
'PDF、Word、TXT、Markdownなどのドキュメントファイルをサポートしています',
uploadSuccess: 'ファイルのアップロードに成功しました!',
uploadError: 'ファイルのアップロードに失敗しました。再度お試しください',
uploadingFile: 'ファイルをアップロード中...',
actions: 'アクション',
delete: 'ドキュメントを削除',
fileDeleteSuccess: 'ドキュメントの削除に成功しました',
fileDeleteFailed: 'ドキュメントの削除に失敗しました',
processing: '処理中',
completed: '完了',
failed: '失敗',
},
deleteKnowledgeBaseConfirmation:
'本当にこの知識ベースを削除しますか?この知識ベースに紐付けられたドキュメントは削除されます。',
retrieve: '検索テスト',
retrieveTest: '検索テスト',
query: '検索',
queryPlaceholder: '検索内容を入力...',
distance: '距離',
content: '内容',
fileName: 'ファイル名',
noResults: '検索結果がありません',
retrieveError: '検索に失敗しました',
},
register: {
title: 'LangBot を初期化 👋',
description: 'これはLangBotの初回起動です',
@@ -313,6 +401,40 @@ const jaJP = {
initSuccess: '初期化に成功しました。ログインしてください',
initFailed: '初期化に失敗しました:',
},
resetPassword: {
title: 'パスワードをリセット 🔐',
description:
'復旧キーと新しいパスワードを入力して、アカウントのパスワードをリセットします',
recoveryKey: '復旧キー',
recoveryKeyDescription:
'設定ファイル `data/config.yaml` の `system.recovery_key` に保存されています',
newPassword: '新しいパスワード',
enterRecoveryKey: '復旧キーを入力',
enterNewPassword: '新しいパスワードを入力',
recoveryKeyRequired: '復旧キーは必須です',
newPasswordRequired: '新しいパスワードは必須です',
resetPassword: 'パスワードをリセット',
resetting: 'リセット中...',
resetSuccess: 'パスワードのリセットに成功しました。ログインしてください',
resetFailed:
'パスワードのリセットに失敗しました。メールアドレスと復旧キーを確認してください',
backToLogin: 'ログインに戻る',
},
embedding: {
description: 'テキストのベクトル化に使用する埋め込みモデルを管理します',
createModel: '埋め込みモデルを作成',
editModel: '埋め込みモデルを編集',
getModelListError: '埋め込みモデルリストの取得に失敗しました:',
embeddingModels: '埋め込みモデル',
extraParametersDescription:
'リクエストボディに追加されるパラメータencoding_format、dimensions など)',
},
llm: {
description: 'チャットメッセージの生成に使用するLLMモデルを管理します',
llmModels: 'LLMモデル',
extraParametersDescription:
'リクエストボディに追加されるパラメータmax_tokens、temperature、top_p など)',
},
};
export default jaJP;

View File

@@ -2,6 +2,8 @@ const zhHans = {
common: {
login: '登录',
logout: '退出登录',
accountOptions: '账户选项',
account: '账户',
email: '邮箱',
password: '密码',
welcome: '欢迎回到 LangBot 👋',
@@ -39,6 +41,22 @@ const zhHans = {
addRound: '添加回合',
copySuccess: '复制成功',
test: '测试',
forgotPassword: '忘记密码?',
loading: '加载中...',
theme: '主题',
changePassword: '修改密码',
currentPassword: '当前密码',
newPassword: '新密码',
confirmNewPassword: '确认新密码',
enterCurrentPassword: '输入当前密码',
enterNewPassword: '输入新密码',
enterConfirmPassword: '确认新密码',
currentPasswordRequired: '当前密码不能为空',
newPasswordRequired: '新密码不能为空',
confirmPasswordRequired: '确认密码不能为空',
passwordsDoNotMatch: '两次输入的密码不一致',
changePasswordSuccess: '密码修改成功',
changePasswordFailed: '密码修改失败,请检查当前密码是否正确',
},
notFound: {
title: '页面不存在',
@@ -86,13 +104,12 @@ const zhHans = {
string: '字符串',
number: '数字',
boolean: '布尔值',
extraParametersDescription:
'将在请求时附加到请求体中,如 max_tokens, temperature, top_p 等',
selectModelProvider: '选择模型供应商',
modelProviderDescription: '请填写供应商向您提供的模型名称',
selectModel: '请选择模型',
testSuccess: '测试成功',
testError: '测试失败,请检查模型配置',
llmModels: '对话模型',
},
bots: {
title: '机器人',
@@ -257,6 +274,10 @@ const zhHans = {
today: '今天',
updateTime: '更新于',
defaultBadge: '默认',
sortBy: '排序方式',
newestCreated: '最新创建',
recentlyEdited: '最近编辑',
earliestEdited: '最早编辑',
basicInfo: '基础信息',
aiCapabilities: 'AI 能力',
triggerConditions: '触发条件',
@@ -291,8 +312,68 @@ const zhHans = {
loadMessagesFailed: '加载消息失败',
loadPipelinesFailed: '加载流水线失败',
atTips: '提及机器人',
streaming: '流式传输',
},
},
knowledge: {
title: '知识库',
createKnowledgeBase: '创建知识库',
editKnowledgeBase: '编辑知识库',
selectKnowledgeBase: '选择知识库',
empty: '无',
editDocument: '文档',
description: '配置可用于提升模型回复质量的知识库',
metadata: '元数据',
documents: '文档',
kbNameRequired: '知识库名称不能为空',
kbDescriptionRequired: '知识库描述不能为空',
embeddingModelUUIDRequired: '嵌入模型不能为空',
daysAgo: '天前',
today: '今天',
kbName: '知识库名称',
kbDescription: '知识库描述',
topK: '召回数量',
topKRequired: '召回数量不能为空',
topKMax: '召回数量最大值为 30',
topKdescription: '召回相关文档块的数量,取值范围为 1-30',
defaultDescription: '一个知识库',
embeddingModelUUID: '嵌入模型',
selectEmbeddingModel: '选择嵌入模型',
embeddingModelDescription: '用于向量化文本,可在模型配置页面配置',
updateTime: '更新于',
cannotChangeEmbeddingModel: '知识库创建后不可修改嵌入模型',
updateKnowledgeBaseSuccess: '知识库更新成功',
updateKnowledgeBaseFailed: '知识库更新失败',
documentsTab: {
name: '名称',
status: '状态',
noResults: '暂无文档',
dragAndDrop: '拖拽文件到此处或点击上传',
uploading: '上传中...',
supportedFormats: '支持 PDF、Word、TXT、Markdown、HTML、ZIP 等文档格式',
uploadSuccess: '文件上传成功!',
uploadError: '文件上传失败,请重试',
uploadingFile: '上传文件中...',
actions: '操作',
delete: '删除文件',
fileDeleteSuccess: '文件删除成功',
fileDeleteFailed: '文件删除失败',
processing: '处理中',
completed: '完成',
failed: '失败',
},
deleteKnowledgeBaseConfirmation:
'你确定要删除这个知识库吗?此知识库下的所有文档将被删除。',
retrieve: '检索测试',
retrieveTest: '检索测试',
query: '查询',
queryPlaceholder: '输入查询内容...',
distance: '距离',
content: '内容',
fileName: '文件名',
noResults: '暂无结果',
retrieveError: '检索失败',
},
register: {
title: '初始化 LangBot 👋',
description: '这是您首次启动 LangBot',
@@ -301,6 +382,38 @@ const zhHans = {
initSuccess: '初始化成功 请登录',
initFailed: '初始化失败:',
},
resetPassword: {
title: '重置密码 🔐',
description: '输入恢复密钥和新的密码来重置您的账户密码',
recoveryKey: '恢复密钥',
recoveryKeyDescription:
'存储在配置文件`data/config.yaml`的`system.recovery_key`中',
newPassword: '新密码',
enterRecoveryKey: '输入恢复密钥',
enterNewPassword: '输入新密码',
recoveryKeyRequired: '恢复密钥不能为空',
newPasswordRequired: '新密码不能为空',
resetPassword: '重置密码',
resetting: '重置中...',
resetSuccess: '密码重置成功,请登录',
resetFailed: '密码重置失败,请检查邮箱和恢复密钥是否正确',
backToLogin: '返回登录',
},
embedding: {
description: '管理嵌入模型,用于向量化文本',
createModel: '创建嵌入模型',
editModel: '编辑嵌入模型',
getModelListError: '获取嵌入模型列表失败:',
embeddingModels: '嵌入模型',
extraParametersDescription:
'将在请求时附加到请求体中,如 encoding_format, dimensions 等',
},
llm: {
llmModels: '对话模型',
description: '管理 LLM 模型,用于对话消息生成',
extraParametersDescription:
'将在请求时附加到请求体中,如 max_tokens, temperature, top_p 等',
},
};
export default zhHans;

View File

@@ -0,0 +1,350 @@
const zhHant = {
common: {
login: '登入',
logout: '登出',
accountOptions: '帳戶選項',
account: '帳戶',
email: '電子郵件',
password: '密碼',
welcome: '歡迎回到 LangBot 👋',
continueToLogin: '登入以繼續',
loginSuccess: '登入成功',
loginFailed: '登入失敗,請檢查電子郵件和密碼是否正確',
enterEmail: '輸入電子郵件地址',
enterPassword: '輸入密碼',
invalidEmail: '請輸入有效的電子郵件地址',
emptyPassword: '請輸入密碼',
language: '語言',
helpDocs: '輔助說明',
create: '建立',
edit: '編輯',
delete: '刪除',
add: '新增',
select: '請選擇',
cancel: '取消',
submit: '提交',
error: '錯誤',
success: '成功',
save: '儲存',
saving: '儲存中...',
confirm: '確認',
confirmDelete: '確認刪除',
deleteConfirmation: '您確定要刪除這個嗎?',
selectOption: '選擇一個選項',
required: '必填',
enable: '是否啟用',
name: '名稱',
description: '描述',
close: '關閉',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
addRound: '新增回合',
copySuccess: '複製成功',
test: '測試',
forgotPassword: '忘記密碼?',
loading: '載入中...',
theme: '主題',
changePassword: '修改密碼',
currentPassword: '當前密碼',
newPassword: '新密碼',
confirmNewPassword: '確認新密碼',
enterCurrentPassword: '輸入當前密碼',
enterNewPassword: '輸入新密碼',
enterConfirmPassword: '確認新密碼',
currentPasswordRequired: '當前密碼不能為空',
newPasswordRequired: '新密碼不能為空',
confirmPasswordRequired: '確認密碼不能為空',
passwordsDoNotMatch: '兩次輸入的密碼不一致',
changePasswordSuccess: '密碼修改成功',
changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確',
},
notFound: {
title: '頁面不存在',
description:
'您要查詢的頁面似乎不存在。請檢查您輸入的 URL 是否正確,或返回首頁。',
back: '上一級',
home: '返回主頁',
help: '查看說明文件',
},
models: {
title: '模型設定',
description: '設定和管理可在流程線中使用的模型',
createModel: '建立模型',
editModel: '編輯模型',
getModelListError: '取得模型清單失敗:',
modelName: '模型名稱',
modelProvider: '模型供應商',
modelBaseURL: '基礎 URL',
modelAbilities: '模型能力',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
createSuccess: '建立成功',
createError: '建立失敗:',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
deleteConfirmation: '您確定要刪除這個模型嗎?',
modelNameRequired: '模型名稱不能為空',
modelProviderRequired: '模型供應商不能為空',
requestURLRequired: '請求URL不能為空',
apiKeyRequired: 'API Key不能為空',
keyNameRequired: '鍵名不能為空',
mustBeValidNumber: '必須是有效的數字',
mustBeTrueOrFalse: '必須是 true 或 false',
requestURL: '請求URL',
apiKey: 'API Key',
abilities: '能力',
selectModelAbilities: '選擇模型能力',
visionAbility: '視覺能力',
functionCallAbility: '函數呼叫',
extraParameters: '額外參數',
addParameter: '新增參數',
keyName: '鍵名',
type: '類型',
value: '值',
string: '字串',
number: '數字',
boolean: '布林值',
selectModelProvider: '選擇模型供應商',
modelProviderDescription: '請填寫供應商向您提供的模型名稱',
selectModel: '請選擇模型',
testSuccess: '測試成功',
testError: '測試失敗,請檢查模型設定',
llmModels: '對話模型',
},
bots: {
title: '機器人',
description: '建立和管理機器人,這是 LangBot 與各個平台連接的入口',
createBot: '建立機器人',
editBot: '編輯機器人',
getBotListError: '取得機器人清單失敗:',
botName: '機器人名稱',
botDescription: '機器人描述',
botNameRequired: '機器人名稱不能為空',
botDescriptionRequired: '機器人描述不能為空',
adapterRequired: '適配器不能為空',
defaultDescription: '一個機器人',
getBotConfigError: '取得機器人設定失敗:',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
createSuccess: '建立成功 請啟用或修改綁定流程線',
createError: '建立失敗:',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
deleteConfirmation: '您確定要刪除這個機器人嗎?',
platformAdapter: '平台/適配器選擇',
selectAdapter: '選擇適配器',
adapterConfig: '適配器設定',
bindPipeline: '綁定流程線',
selectPipeline: '選擇流程線',
botLogTitle: '機器人日誌',
enableAutoRefresh: '開啟自動重新整理',
session: '對話',
yesterday: '昨天',
earlier: '更久之前',
dateFormat: '{{month}}月{{day}}日',
setBotEnableError: '設定機器人啟用狀態失敗',
log: '日誌',
configuration: '設定',
logs: '日誌',
},
plugins: {
title: '外掛管理',
description: '安裝和設定用於擴展 LangBot 功能的外掛',
createPlugin: '建立外掛',
editPlugin: '編輯外掛',
installed: '已安裝',
marketplace: 'Marketplace',
arrange: '編排',
install: '安裝',
installFromGithub: '從 GitHub 安裝外掛',
onlySupportGithub: '目前僅支援從 GitHub 安裝',
enterGithubLink: '請輸入外掛的Github連結',
installing: '正在安裝外掛...',
installSuccess: '外掛安裝成功',
installFailed: '外掛安裝失敗:',
searchPlugin: '搜尋外掛',
sortBy: '排序方式',
mostStars: '最多星標',
recentlyAdded: '最近新增',
recentlyUpdated: '最近更新',
noMatchingPlugins: '沒有找到符合的外掛',
loading: '載入中...',
getPluginListError: '取得外掛清單失敗:',
pluginConfig: '外掛設定',
noPluginInstalled: '暫未安裝任何外掛',
pluginSort: '外掛排序',
pluginSortDescription:
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',
pluginSortSuccess: '外掛排序成功',
pluginSortError: '外掛排序失敗:',
pluginNoConfig: '外掛沒有設定項目。',
deleting: '刪除中...',
deletePlugin: '刪除外掛',
cancel: '取消',
saveConfig: '儲存設定',
saving: '儲存中...',
confirmDeletePlugin: '您確定要刪除外掛({{author}}/{{name}})嗎?',
confirmDelete: '確認刪除',
deleteError: '刪除失敗:',
close: '關閉',
deleteConfirm: '刪除確認',
modifyFailed: '修改失敗:',
eventCount: '事件:{{count}}',
toolCount: '工具:{{count}}',
starCount: '星標:{{count}}',
},
pipelines: {
title: '流程線',
description: '流程線定義了對訊息事件的處理流程,用於綁定到機器人',
createPipeline: '建立流程線',
editPipeline: '編輯流程線',
chat: '對話',
configuration: '設定',
debugChat: '對話除錯',
getPipelineListError: '取得流程線清單失敗:',
daysAgo: '天前',
today: '今天',
updateTime: '更新於',
defaultBadge: '預設',
sortBy: '排序方式',
newestCreated: '最新建立',
recentlyEdited: '最近編輯',
earliestEdited: '最早編輯',
basicInfo: '基本資訊',
aiCapabilities: 'AI 能力',
triggerConditions: '觸發條件',
safetyControls: '安全控制',
outputProcessing: '輸出處理',
nameRequired: '名稱不能為空',
descriptionRequired: '描述不能為空',
createSuccess: '建立成功 請編輯流程線詳細參數',
createError: '建立失敗:',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
deleteConfirmation:
'您確定要刪除這個流程線嗎?已綁定此流程線的機器人將無法使用。',
defaultPipelineCannotDelete: '預設流程線不可刪除',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
debugDialog: {
title: '流程線對話',
selectPipeline: '選擇流程線',
sessionType: '對話類型',
privateChat: '私聊',
groupChat: '群聊',
send: '傳送',
reset: '重設對話',
inputPlaceholder: '傳送 {{type}} 訊息...',
noMessages: '暫無訊息',
userMessage: '使用者',
botMessage: '機器人',
sendFailed: '傳送失敗',
resetSuccess: '對話已重設',
resetFailed: '重設失敗',
loadMessagesFailed: '載入訊息失敗',
loadPipelinesFailed: '載入流程線失敗',
atTips: '提及機器人',
},
},
knowledge: {
title: '知識庫',
createKnowledgeBase: '建立知識庫',
editKnowledgeBase: '編輯知識庫',
selectKnowledgeBase: '選擇知識庫',
empty: '無',
editDocument: '文檔',
description: '設定可用於提升模型回覆品質的知識庫',
metadata: '中繼資料',
documents: '文檔',
kbNameRequired: '知識庫名稱不能為空',
kbDescriptionRequired: '知識庫描述不能為空',
embeddingModelUUIDRequired: '嵌入模型不能為空',
daysAgo: '天前',
today: '今天',
kbName: '知識庫名稱',
kbDescription: '知識庫描述',
topK: '召回數量 ',
topKRequired: '召回數量不能為空',
topKMax: '召回數量最大值為30',
topKdescription: '取得相關性高的上位 K 件文獻的數量範圍為130',
defaultDescription: '一個知識庫',
embeddingModelUUID: '嵌入模型',
selectEmbeddingModel: '選擇嵌入模型',
embeddingModelDescription: '用於向量化文字,可在模型設定頁面設定',
updateTime: '更新於',
cannotChangeEmbeddingModel: '知識庫建立後不可修改嵌入模型',
updateKnowledgeBaseSuccess: '知識庫更新成功',
updateKnowledgeBaseFailed: '知識庫更新失敗',
documentsTab: {
name: '名稱',
status: '狀態',
noResults: '暫無文件',
dragAndDrop: '拖曳文檔到此處或點擊上傳',
uploading: '上傳中...',
supportedFormats: '支援 PDF、Word、TXT、Markdown 等文檔格式',
uploadSuccess: '文檔上傳成功!',
uploadError: '文檔上傳失敗,請重試',
uploadingFile: '上傳文檔中...',
actions: '操作',
delete: '刪除文檔',
fileDeleteSuccess: '文檔刪除成功',
fileDeleteFailed: '文檔刪除失敗',
processing: '處理中',
completed: '完成',
failed: '失敗',
},
deleteKnowledgeBaseConfirmation:
'您確定要刪除這個知識庫嗎?此知識庫下的所有文檔將被刪除。',
retrieve: '檢索測試',
retrieveTest: '檢索測試',
query: '查詢',
queryPlaceholder: '輸入查詢內容...',
distance: '距離',
content: '內容',
fileName: '文檔名稱',
noResults: '暫無結果',
retrieveError: '檢索失敗',
},
register: {
title: '初始化 LangBot 👋',
description: '這是您首次啟動 LangBot',
adminAccountNote: '您填寫的電子郵件和密碼將作為初始管理員帳號',
register: '註冊',
initSuccess: '初始化成功 請登入',
initFailed: '初始化失敗:',
},
resetPassword: {
title: '重設密碼 🔐',
description: '輸入恢復金鑰和新的密碼來重設您的帳戶密碼',
recoveryKey: '恢復金鑰',
recoveryKeyDescription:
'儲存在設定檔案`data/config.yaml`的`system.recovery_key`中',
newPassword: '新密碼',
enterRecoveryKey: '輸入恢復金鑰',
enterNewPassword: '輸入新密碼',
recoveryKeyRequired: '恢復金鑰不能為空',
newPasswordRequired: '新密碼不能為空',
resetPassword: '重設密碼',
resetting: '重設中...',
resetSuccess: '密碼重設成功,請登入',
resetFailed: '密碼重設失敗,請檢查電子郵件和恢復金鑰是否正確',
backToLogin: '返回登入',
},
embedding: {
description: '管理嵌入模型,用於向量化文字',
createModel: '建立嵌入模型',
editModel: '編輯嵌入模型',
getModelListError: '取得嵌入模型清單失敗:',
embeddingModels: '嵌入模型',
extraParametersDescription:
'將在請求時附加到請求體中,如 encoding_format, dimensions 等',
},
llm: {
llmModels: '對話模型',
description: '管理 LLM 模型,用於對話訊息產生',
extraParametersDescription:
'將在請求時附加到請求體中,如 max_tokens, temperature, top_p 等',
},
};
export default zhHant;