mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Merge branch 'rc/new-plugin' into refactor/new-plugin-system
This commit is contained in:
1
web/.env.example
Normal file
1
web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:5300
|
||||
1
web/.gitignore
vendored
1
web/.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
@@ -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
918
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.dark) .botLogCardContainer {
|
||||
background-color: #1f1f22;
|
||||
border: 1px solid #2a2a2e;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function BotConfigPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.configPageContainer}>
|
||||
<div>
|
||||
<BotDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
253
web/src/app/home/knowledge/KBDetailDialog.tsx
Normal file
253
web/src/app/home/knowledge/KBDetailDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
web/src/app/home/knowledge/components/kb-card/KBCard.module.css
Normal file
144
web/src/app/home/knowledge/components/kb-card/KBCard.module.css
Normal 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;
|
||||
}
|
||||
36
web/src/app/home/knowledge/components/kb-card/KBCard.tsx
Normal file
36
web/src/app/home/knowledge/components/kb-card/KBCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
web/src/app/home/knowledge/components/kb-card/KBCardVO.ts
Normal file
26
web/src/app/home/knowledge/components/kb-card/KBCardVO.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
145
web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx
Normal file
145
web/src/app/home/knowledge/components/kb-docs/FileUploadZone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
web/src/app/home/knowledge/components/kb-docs/KBDoc.tsx
Normal file
72
web/src/app/home/knowledge/components/kb-docs/KBDoc.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IEmbeddingModelEntity {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
267
web/src/app/home/knowledge/components/kb-form/KBForm.tsx
Normal file
267
web/src/app/home/knowledge/components/kb-form/KBForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
15
web/src/app/home/knowledge/knowledgeBase.module.css
Normal file
15
web/src/app/home/knowledge/knowledgeBase.module.css
Normal 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;
|
||||
}
|
||||
115
web/src/app/home/knowledge/page.tsx
Normal file
115
web/src/app/home/knowledge/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface ICreateEmbeddingField {
|
||||
name: string;
|
||||
model_provider: string;
|
||||
url: string;
|
||||
api_key: string;
|
||||
extra_args?: string[];
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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%'}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
15
web/src/app/reset-password/layout.tsx
Normal file
15
web/src/app/reset-password/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
web/src/app/reset-password/page.tsx
Normal file
207
web/src/app/reset-password/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
web/src/components/providers/theme-provider.tsx
Normal file
18
web/src/components/providers/theme-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
252
web/src/components/ui/context-menu.tsx
Normal file
252
web/src/components/ui/context-menu.tsx
Normal 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,
|
||||
};
|
||||
77
web/src/components/ui/input-otp.tsx
Normal file
77
web/src/components/ui/input-otp.tsx
Normal 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 };
|
||||
98
web/src/components/ui/language-selector.tsx
Normal file
98
web/src/components/ui/language-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
116
web/src/components/ui/table.tsx
Normal file
116
web/src/components/ui/table.tsx
Normal 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,
|
||||
};
|
||||
18
web/src/components/ui/textarea.tsx
Normal file
18
web/src/components/ui/textarea.tsx
Normal 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 };
|
||||
23
web/src/components/ui/theme-toggle.tsx
Normal file
23
web/src/components/ui/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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件の文書の数。1~30の範囲で設定できます',
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
350
web/src/i18n/locales/zh-Hant.ts
Normal file
350
web/src/i18n/locales/zh-Hant.ts
Normal 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 件文獻的數量,範圍為1~30',
|
||||
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;
|
||||
Reference in New Issue
Block a user