mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 21:06:03 +00:00
Merge branch 'rc/new-plugin' into refactor/new-plugin-system
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user