mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 07:16:04 +00:00
Merge remote-tracking branch 'origin/master' into feat/sandbox
# Conflicts: # src/langbot/pkg/api/http/controller/groups/plugins.py # src/langbot/pkg/core/app.py # src/langbot/pkg/core/stages/build_app.py # src/langbot/templates/config.yaml # uv.lock # web/src/app/home/components/home-sidebar/HomeSidebar.tsx # web/src/app/home/components/home-sidebar/SidebarDataContext.tsx # web/src/app/home/layout.tsx # web/src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx # web/src/i18n/locales/en-US.ts # web/src/i18n/locales/es-ES.ts # web/src/i18n/locales/ja-JP.ts # web/src/i18n/locales/th-TH.ts # web/src/i18n/locales/vi-VN.ts # web/src/i18n/locales/zh-Hans.ts # web/src/i18n/locales/zh-Hant.ts # web/src/router.tsx
This commit is contained in:
@@ -174,11 +174,14 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{activeTab === 'config' && (
|
||||
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="bot-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
|
||||
@@ -618,6 +618,8 @@ export default function BotForm({
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
bot_uuid: initBotId || '',
|
||||
adapter_config: form.getValues('adapter_config') || {},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
|
||||
const LEVEL_STYLES: Record<string, string> = {
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
@@ -31,36 +32,19 @@ export function BotLogCard({
|
||||
|
||||
function copySessionId() {
|
||||
const text = botLog.message_session_id;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
copyToClipboard(text)
|
||||
.then((ok) => {
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
})
|
||||
.catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text: string) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
} else {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('common.copyFailed'));
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
} from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import {
|
||||
MessageChainComponent,
|
||||
Plain,
|
||||
@@ -108,10 +109,9 @@ const BotSessionMonitor = forwardRef<
|
||||
};
|
||||
|
||||
const copyUserId = (userId: string) => {
|
||||
navigator.clipboard.writeText(userId).then(() => {
|
||||
setCopiedUserId(true);
|
||||
setTimeout(() => setCopiedUserId(false), 2000);
|
||||
});
|
||||
copyToClipboard(userId).catch(() => {});
|
||||
setCopiedUserId(true);
|
||||
setTimeout(() => setCopiedUserId(false), 2000);
|
||||
};
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||
@@ -86,6 +86,9 @@ export default function ApiIntegrationDialog({
|
||||
const [newWebhookDescription, setNewWebhookDescription] = useState('');
|
||||
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
|
||||
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
|
||||
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
// Sync URL with dialog state
|
||||
@@ -182,10 +185,29 @@ export default function ApiIntegrationDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = text;
|
||||
el.style.cssText =
|
||||
'position:fixed;opacity:0;pointer-events:none;white-space:pre;';
|
||||
document.body.appendChild(el);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
document.execCommand('copy');
|
||||
sel?.removeAllRanges();
|
||||
document.body.removeChild(el);
|
||||
};
|
||||
|
||||
const handleCopyKey = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
try {
|
||||
copyToClipboard(key);
|
||||
} catch {}
|
||||
clearTimeout(copiedTimerRef.current);
|
||||
setCopiedKey(key);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
const maskApiKey = (key: string) => {
|
||||
@@ -330,21 +352,21 @@ export default function ApiIntegrationDialog({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
{apiKeys.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{key.name}</div>
|
||||
{key.description && (
|
||||
<div className="font-medium">{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{key.description}
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(key.key)}
|
||||
{maskApiKey(item.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -352,10 +374,11 @@ export default function ApiIntegrationDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyKey(key.key)}
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === key.key ? (
|
||||
{copiedKey === item.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -364,7 +387,7 @@ export default function ApiIntegrationDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(key.id)}
|
||||
onClick={() => setDeleteKeyId(item.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -630,44 +653,6 @@ export default function ApiIntegrationDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete API Key Confirmation Dialog */}
|
||||
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('common.apiKeyCreatedMessage')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t('common.apiKeyValue')}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input value={createdKey?.key || ''} readOnly />
|
||||
<Button
|
||||
onClick={() => createdKey && handleCopyKey(createdKey.key)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
{copiedKey === createdKey?.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setCreatedKey(null)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteKeyId}>
|
||||
<AlertDialogPortal>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Globe } from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
|
||||
/**
|
||||
@@ -44,6 +45,54 @@ function resolveShowIfValue(
|
||||
return externalDependentValues?.[field];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only component for embed code fields with copy animation.
|
||||
*/
|
||||
function EmbedCodeField({
|
||||
label,
|
||||
description,
|
||||
snippet,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
snippet: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(snippet).catch(() => {});
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium leading-none">{label}</label>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<pre className="flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
|
||||
<code>{snippet}</code>
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only component for webhook URL fields.
|
||||
* Rendered outside of react-hook-form binding since the value is
|
||||
@@ -65,15 +114,9 @@ function WebhookUrlField({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopy = (text: string, setter: (v: boolean) => void) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setter(true);
|
||||
setTimeout(() => setter(false), 2000);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
copyToClipboard(text).catch(() => {});
|
||||
setter(true);
|
||||
setTimeout(() => setter(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -203,10 +246,13 @@ export default function DynamicFormComponent({
|
||||
return value;
|
||||
};
|
||||
|
||||
// Filter out display-only field types (e.g. webhook-url) that should not
|
||||
// Filter out display-only field types (e.g. webhook-url, embed-code) that should not
|
||||
// participate in form state, validation, or value emission.
|
||||
const editableItems = useMemo(
|
||||
() => itemConfigList.filter((item) => item.type !== 'webhook-url'),
|
||||
() =>
|
||||
itemConfigList.filter(
|
||||
(item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
|
||||
),
|
||||
[itemConfigList],
|
||||
);
|
||||
|
||||
@@ -447,6 +493,36 @@ export default function DynamicFormComponent({
|
||||
);
|
||||
}
|
||||
|
||||
if (config.type === 'embed-code') {
|
||||
const botUuid = (systemContext?.bot_uuid as string) || '';
|
||||
if (!botUuid) return null;
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const widgetTitle =
|
||||
((systemContext?.adapter_config as Record<string, unknown>)
|
||||
?.title as string) || 'LangBot';
|
||||
const safeTitle = widgetTitle
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
|
||||
|
||||
return (
|
||||
<EmbedCodeField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
snippet={embedSnippet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (config.type === 'boolean') {
|
||||
return (
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Zap,
|
||||
FilePlus2,
|
||||
Sparkles,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
@@ -57,6 +58,7 @@ import AccountSettingsDialog from '@/app/home/components/account-settings-dialog
|
||||
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
|
||||
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
import { toast } from 'sonner';
|
||||
@@ -780,131 +782,147 @@ function NavItems({
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={false}
|
||||
onClick={() => {
|
||||
if (isCollapseOnly) {
|
||||
onSectionToggle(config.id, !isOpen);
|
||||
} else {
|
||||
onChildClick(config);
|
||||
}
|
||||
}}
|
||||
tooltip={config.name}
|
||||
className="group/category-header"
|
||||
>
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (isCollapseOnly) {
|
||||
onSectionToggle(config.id, !isOpen);
|
||||
} else {
|
||||
onChildClick(config);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (isCollapseOnly) {
|
||||
onSectionToggle(config.id, !isOpen);
|
||||
} else {
|
||||
onChildClick(config);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/market');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/market');
|
||||
setPendingPluginInstallAction('local');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('local');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('github');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('create');
|
||||
navigate('/home/skills');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('upload');
|
||||
navigate('/home/skills');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('github');
|
||||
navigate('/home/skills');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('github');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('create');
|
||||
navigate('/home/skills');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('upload');
|
||||
navigate('/home/skills');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingSkillInstallAction('github');
|
||||
navigate('/home/skills');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
className="p-1 rounded-sm hover:bg-sidebar-accent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</button>
|
||||
))}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm hover:bg-sidebar-accent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
<CollapsibleContent>
|
||||
@@ -1148,6 +1166,127 @@ function PluginItemMenu({
|
||||
);
|
||||
}
|
||||
|
||||
// Plugin pages navigation section — grouped by plugin
|
||||
function PluginPagesNav() {
|
||||
const { pluginPages } = useSidebarData();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (pluginPages.length === 0) return null;
|
||||
|
||||
const pathname = location.pathname;
|
||||
const currentId =
|
||||
pathname === '/home/plugin-pages' ? searchParams.get('id') : null;
|
||||
|
||||
// Group pages by plugin (author/name)
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ label: string; iconURL: string; pages: typeof pluginPages }
|
||||
>();
|
||||
for (const page of pluginPages) {
|
||||
const key = `${page.pluginAuthor}/${page.pluginName}`;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, {
|
||||
label: page.pluginLabel,
|
||||
iconURL: page.pluginIconURL,
|
||||
pages: [],
|
||||
});
|
||||
}
|
||||
grouped.get(key)!.pages.push(page);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel title={t('sidebar.pluginPagesTooltip')}>
|
||||
{t('sidebar.pluginPages')}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{Array.from(grouped.entries()).map(
|
||||
([pluginKey, { label, iconURL, pages }]) => {
|
||||
const hasActivePage = pages.some((p) => p.id === currentId);
|
||||
|
||||
const pluginIcon = (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt=""
|
||||
className="size-4 rounded-sm object-cover shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Single page — render directly without nesting
|
||||
if (pages.length === 1) {
|
||||
const page = pages[0];
|
||||
const isActive = currentId === page.id;
|
||||
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
|
||||
return (
|
||||
<SidebarMenuItem key={page.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
tooltip={page.name}
|
||||
onClick={() => navigate(route)}
|
||||
className="select-none"
|
||||
>
|
||||
{pluginIcon}
|
||||
<span>{page.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple pages — collapsible group
|
||||
return (
|
||||
<Collapsible
|
||||
key={pluginKey}
|
||||
defaultOpen={hasActivePage}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={label}
|
||||
className="select-none"
|
||||
>
|
||||
{pluginIcon}
|
||||
<span>{label}</span>
|
||||
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{pages.map((page) => {
|
||||
const isActive = currentId === page.id;
|
||||
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
|
||||
return (
|
||||
<SidebarMenuSubItem key={page.id}>
|
||||
<SidebarMenuSubButton
|
||||
isActive={isActive}
|
||||
onClick={() => navigate(route)}
|
||||
className="select-none"
|
||||
>
|
||||
<span>{page.name}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeSidebar({
|
||||
onSelectedChangeAction,
|
||||
}: {
|
||||
@@ -1173,6 +1312,9 @@ export default function HomeSidebar({
|
||||
if (searchParams.get('action') === 'showApiIntegrationSettings') {
|
||||
setApiKeyDialogOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showStorageAnalysis') {
|
||||
setStorageAnalysisOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
|
||||
@@ -1188,6 +1330,7 @@ export default function HomeSidebar({
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState<string>('');
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
@@ -1227,6 +1370,24 @@ export default function HomeSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
function handleStorageAnalysisChange(open: boolean) {
|
||||
setStorageAnalysisOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showStorageAnalysis');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
initSelect();
|
||||
if (!localStorage.getItem('token')) {
|
||||
@@ -1410,6 +1571,7 @@ export default function HomeSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<PluginPagesNav />
|
||||
</SidebarContent>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -1524,6 +1686,15 @@ export default function HomeSidebar({
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
handleStorageAnalysisChange(true);
|
||||
}}
|
||||
>
|
||||
<HardDrive />
|
||||
{t('storageAnalysis.title')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
@@ -1624,6 +1795,10 @@ export default function HomeSidebar({
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
<StorageAnalysisDialog
|
||||
open={storageAnalysisOpen}
|
||||
onOpenChange={handleStorageAnalysisChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,18 @@ export interface SidebarEntityItem {
|
||||
export type PluginInstallAction = 'local' | 'github' | null;
|
||||
export type SkillInstallAction = 'create' | 'github' | 'upload' | null;
|
||||
|
||||
// Plugin page registered by a plugin
|
||||
export interface PluginPageItem {
|
||||
id: string; // "author/name/pageId"
|
||||
name: string; // display label
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
pluginLabel: string; // human-readable plugin display name
|
||||
pluginIconURL: string; // plugin icon URL
|
||||
pageId: string;
|
||||
path: string; // asset path (HTML file)
|
||||
}
|
||||
|
||||
// Entity lists and refresh functions exposed via context
|
||||
export interface SidebarDataContextValue {
|
||||
bots: SidebarEntityItem[];
|
||||
@@ -40,6 +52,7 @@ export interface SidebarDataContextValue {
|
||||
plugins: SidebarEntityItem[];
|
||||
mcpServers: SidebarEntityItem[];
|
||||
skills: SidebarEntityItem[];
|
||||
pluginPages: PluginPageItem[];
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
@@ -71,6 +84,7 @@ export function SidebarDataProvider({
|
||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
|
||||
useState<PluginInstallAction>(null);
|
||||
@@ -146,33 +160,69 @@ export function SidebarDataProvider({
|
||||
}
|
||||
}
|
||||
|
||||
setPlugins(
|
||||
pluginsResp.plugins.map((plugin) => {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const compositeKey = `${author}/${name}`;
|
||||
const installedVersion = meta.version ?? '';
|
||||
// Deduplicate plugins by composite key (prefer debug over installed)
|
||||
const pluginMap = new Map<string, SidebarEntityItem>();
|
||||
for (const plugin of pluginsResp.plugins) {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const compositeKey = `${author}/${name}`;
|
||||
const installedVersion = meta.version ?? '';
|
||||
|
||||
let hasUpdate = false;
|
||||
if (plugin.install_source === 'marketplace') {
|
||||
const latestVersion = marketplaceVersions.get(compositeKey);
|
||||
if (latestVersion) {
|
||||
hasUpdate = isNewerVersion(latestVersion, installedVersion);
|
||||
let hasUpdate = false;
|
||||
if (plugin.install_source === 'marketplace') {
|
||||
const latestVersion = marketplaceVersions.get(compositeKey);
|
||||
if (latestVersion) {
|
||||
hasUpdate = isNewerVersion(latestVersion, installedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
const item: SidebarEntityItem = {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
debug: plugin.debug,
|
||||
};
|
||||
|
||||
// If duplicate, prefer debug version
|
||||
if (!pluginMap.has(compositeKey) || plugin.debug) {
|
||||
pluginMap.set(compositeKey, item);
|
||||
}
|
||||
}
|
||||
setPlugins(Array.from(pluginMap.values()));
|
||||
|
||||
// Extract plugin pages from spec.pages (deduplicate by id)
|
||||
const pages: PluginPageItem[] = [];
|
||||
const seenPageIds = new Set<string>();
|
||||
for (const plugin of pluginsResp.plugins) {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const label = meta.label ? extractI18nObject(meta.label) : name;
|
||||
const spec = plugin.manifest.manifest.spec;
|
||||
if (spec?.pages && Array.isArray(spec.pages)) {
|
||||
for (const page of spec.pages) {
|
||||
const pageId = `${author}/${name}/${page.id}`;
|
||||
if (page.id && page.path && !seenPageIds.has(pageId)) {
|
||||
seenPageIds.add(pageId);
|
||||
pages.push({
|
||||
id: pageId,
|
||||
name: page.label ? extractI18nObject(page.label) : page.id,
|
||||
pluginAuthor: author,
|
||||
pluginName: name,
|
||||
pluginLabel: label,
|
||||
pluginIconURL: httpClient.getPluginIconURL(author, name),
|
||||
pageId: page.id,
|
||||
path: page.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
debug: plugin.debug,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
setPluginPages(pages);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugins for sidebar:', error);
|
||||
}
|
||||
@@ -242,6 +292,7 @@ export function SidebarDataProvider({
|
||||
plugins,
|
||||
mcpServers,
|
||||
skills,
|
||||
pluginPages,
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
|
||||
@@ -29,15 +29,36 @@ interface ModelsDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
|
||||
|
||||
function convertExtraArgsToObject(
|
||||
args: ExtraArg[],
|
||||
): Record<string, string | number | boolean> {
|
||||
const obj: Record<string, string | number | boolean> = {};
|
||||
): Record<string, ExtraArgValue> {
|
||||
const obj: Record<string, ExtraArgValue> = {};
|
||||
args.forEach((arg) => {
|
||||
if (arg.key.trim()) {
|
||||
if (arg.type === 'number') obj[arg.key] = Number(arg.value);
|
||||
else if (arg.type === 'boolean') obj[arg.key] = arg.value === 'true';
|
||||
else obj[arg.key] = arg.value;
|
||||
if (!arg.key.trim()) return;
|
||||
if (arg.type === 'number') {
|
||||
obj[arg.key] = Number(arg.value);
|
||||
} else if (arg.type === 'boolean') {
|
||||
obj[arg.key] = arg.value === 'true';
|
||||
} else if (arg.type === 'object') {
|
||||
const raw = arg.value.trim() || '{}';
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON for extra parameter "${arg.key}"`);
|
||||
}
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new Error(`Extra parameter "${arg.key}" must be a JSON object`);
|
||||
}
|
||||
obj[arg.key] = parsed as Record<string, unknown>;
|
||||
} else {
|
||||
obj[arg.key] = arg.value;
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
|
||||
@@ -258,11 +258,16 @@ export default function AddModelPopover({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] overflow-y-auto"
|
||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
style={{
|
||||
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
|
||||
}}
|
||||
align="end"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
|
||||
@@ -437,7 +442,7 @@ export default function AddModelPopover({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
|
||||
className="h-64 overflow-y-auto overscroll-none rounded-md border"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-3 space-y-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Plus, X, HelpCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
@@ -47,9 +48,30 @@ export default function ExtraArgsEditor({
|
||||
) => {
|
||||
const newArgs = [...args];
|
||||
newArgs[index] = { ...newArgs[index], [field]: value };
|
||||
// When switching to object type, seed an empty JSON object so the textarea
|
||||
// doesn't start with an unparseable empty string.
|
||||
if (
|
||||
field === 'type' &&
|
||||
value === 'object' &&
|
||||
!newArgs[index].value.trim()
|
||||
) {
|
||||
newArgs[index].value = '{}';
|
||||
}
|
||||
onChange(newArgs);
|
||||
};
|
||||
|
||||
const isInvalidJson = (raw: string) => {
|
||||
if (!raw.trim()) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return (
|
||||
parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -90,49 +112,79 @@ export default function ExtraArgsEditor({
|
||||
{args.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('common.none')}</p>
|
||||
) : (
|
||||
args.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={arg.type}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => handleUpdate(index, 'type', value)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</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}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
|
||||
/>
|
||||
{!disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
args.map((arg, index) => {
|
||||
const isObject = arg.type === 'object';
|
||||
const jsonError = isObject && isInvalidJson(arg.value);
|
||||
return (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex gap-2 items-start">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
className={isObject ? 'flex-[2]' : 'flex-1'}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={arg.type}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => handleUpdate(index, 'type', value)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">{t('models.string')}</SelectItem>
|
||||
<SelectItem value="number">{t('models.number')}</SelectItem>
|
||||
<SelectItem value="boolean">
|
||||
{t('models.boolean')}
|
||||
</SelectItem>
|
||||
<SelectItem value="object">{t('models.object')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isObject && (
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
handleUpdate(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isObject && (
|
||||
<Textarea
|
||||
placeholder={t('models.objectJsonPlaceholder')}
|
||||
value={arg.value}
|
||||
className={`w-full font-mono text-xs min-h-[96px] resize-y ${
|
||||
jsonError ? 'border-destructive' : ''
|
||||
}`}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{jsonError && (
|
||||
<p className="text-xs text-destructive pl-1">
|
||||
{t('models.invalidJsonObject')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,10 +46,25 @@ interface ModelItemProps {
|
||||
function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
|
||||
if (!extraArgs) return [];
|
||||
return Object.entries(extraArgs).map(([key, value]) => {
|
||||
let type: 'string' | 'number' | 'boolean' = 'string';
|
||||
if (typeof value === 'number') type = 'number';
|
||||
else if (typeof value === 'boolean') type = 'boolean';
|
||||
return { key, type, value: String(value) };
|
||||
let type: ExtraArg['type'] = 'string';
|
||||
let stringValue: string;
|
||||
if (typeof value === 'number') {
|
||||
type = 'number';
|
||||
stringValue = String(value);
|
||||
} else if (typeof value === 'boolean') {
|
||||
type = 'boolean';
|
||||
stringValue = String(value);
|
||||
} else if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
type = 'object';
|
||||
stringValue = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
stringValue = String(value);
|
||||
}
|
||||
return { key, type, value: stringValue };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,7 +224,16 @@ export default function ModelItem({
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80" align="start">
|
||||
<PopoverContent
|
||||
className="w-80 max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
align="start"
|
||||
collisionPadding={16}
|
||||
style={{
|
||||
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.modelName')}</Label>
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
|
||||
export type ExtraArg = {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
type: 'string' | 'number' | 'boolean' | 'object';
|
||||
// For 'object' type, value holds a JSON string that will be parsed on save.
|
||||
value: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
Clock,
|
||||
Database,
|
||||
FileWarning,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
|
||||
interface StorageSection {
|
||||
key: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
size_bytes: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface CleanupCandidate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
size_bytes: number;
|
||||
modified_at?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface StorageAnalysis {
|
||||
generated_at: string;
|
||||
cleanup_policy: {
|
||||
uploaded_file_retention_days: number;
|
||||
log_retention_days: number;
|
||||
};
|
||||
sections: StorageSection[];
|
||||
database: {
|
||||
type: string;
|
||||
monitoring_counts: Record<string, number>;
|
||||
binary_storage: {
|
||||
count: number;
|
||||
size_bytes: number | null;
|
||||
};
|
||||
};
|
||||
cleanup_candidates: {
|
||||
uploaded_files: CleanupCandidate[];
|
||||
log_files: CleanupCandidate[];
|
||||
};
|
||||
tasks: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
interface StorageAnalysisDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return '-';
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export default function StorageAnalysisDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: StorageAnalysisDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await backendClient.get<StorageAnalysis>(
|
||||
'/api/v1/system/storage-analysis',
|
||||
);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadAnalysis();
|
||||
}
|
||||
}, [loadAnalysis, open]);
|
||||
|
||||
const totalBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const uploadedCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.uploaded_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const logCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.log_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!flex h-[86vh] max-h-[86vh] max-w-5xl flex-col gap-0 p-0">
|
||||
<DialogHeader className="shrink-0 px-6 pt-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDrive className="size-5 text-blue-500" />
|
||||
{t('storageAnalysis.dialogTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('storageAnalysis.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 pb-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{analysis
|
||||
? t('storageAnalysis.generatedAt', {
|
||||
time: new Date(analysis.generated_at).toLocaleString(),
|
||||
})
|
||||
: t('storageAnalysis.loading')}
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadAnalysis}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('storageAnalysis.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.totalSize')}
|
||||
value={formatBytes(totalBytes)}
|
||||
icon={<HardDrive className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.binaryStorage')}
|
||||
value={formatBytes(
|
||||
analysis.database.binary_storage.size_bytes,
|
||||
)}
|
||||
meta={`${analysis.database.binary_storage.count}`}
|
||||
icon={<Database className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.uploadCleanup')}
|
||||
value={formatBytes(uploadedCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.logCleanup')}
|
||||
value={formatBytes(logCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.log_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-md border px-3 py-3">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
{t('storageAnalysis.cleanupPolicy')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.uploadRetention')}
|
||||
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.logRetention')}
|
||||
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.databaseType')}
|
||||
value={analysis.database.type}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.sections')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{analysis.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{t(`storageAnalysis.sectionNames.${section.key}`)}
|
||||
</div>
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
{section.path || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{section.exists ? (
|
||||
<span />
|
||||
) : (
|
||||
<Badge variant="outline" className="self-center">
|
||||
{t('storageAnalysis.missing')}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(section.size_bytes)}
|
||||
</div>
|
||||
<div className="self-center text-muted-foreground tabular-nums">
|
||||
{section.file_count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.monitoringTables')}
|
||||
values={analysis.database.monitoring_counts}
|
||||
/>
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.runtimeTasks')}
|
||||
values={analysis.tasks}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredUploads')}
|
||||
emptyText={t('storageAnalysis.noExpiredUploads')}
|
||||
candidates={analysis.cleanup_candidates.uploaded_files}
|
||||
/>
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredLogs')}
|
||||
emptyText={t('storageAnalysis.noExpiredLogs')}
|
||||
candidates={analysis.cleanup_candidates.log_files}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
meta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
meta?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums">{value}</span>
|
||||
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricPanel({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, number | undefined>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium">{title}</h2>
|
||||
<div className="rounded-md border">
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium tabular-nums">{value ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidatePanel({
|
||||
title,
|
||||
emptyText,
|
||||
candidates,
|
||||
}: {
|
||||
title: string;
|
||||
emptyText: string;
|
||||
candidates: CleanupCandidate[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Archive className="size-4 text-muted-foreground" />
|
||||
{title}
|
||||
</h2>
|
||||
<div className="rounded-md border">
|
||||
{candidates.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
candidates.slice(0, 8).map((candidate, index) => (
|
||||
<div
|
||||
key={`${candidate.key ?? candidate.name}-${index}`}
|
||||
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{candidate.key ?? candidate.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{candidate.modified_at ?? candidate.date ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(candidate.size_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,7 +146,12 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.editKnowledgeBase')}
|
||||
</h1>
|
||||
<Button type="submit" form="kb-form" disabled={!formDirty}>
|
||||
<Button
|
||||
type="submit"
|
||||
form="kb-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'metadata' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,7 @@ const EXTENSIONS_ROUTES = [
|
||||
'/home/market',
|
||||
'/home/mcp',
|
||||
'/home/skills',
|
||||
'/home/plugin-pages',
|
||||
];
|
||||
|
||||
function isExtensionsRoute(pathname: string): boolean {
|
||||
|
||||
@@ -80,7 +80,12 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
|
||||
<Button type="submit" form="pipeline-form" disabled={!formDirty}>
|
||||
<Button
|
||||
type="submit"
|
||||
form="pipeline-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
192
web/src/app/home/plugin-pages/page.tsx
Normal file
192
web/src/app/home/plugin-pages/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
/**
|
||||
* Plugin page that renders a plugin-provided HTML page in an iframe.
|
||||
* URL format: /home/plugin-pages?id=author/name/pageId
|
||||
*
|
||||
* The iframe communicates with the parent via postMessage:
|
||||
*
|
||||
* Parent → iframe:
|
||||
* { type: 'langbot:context', theme: 'light'|'dark', language: 'zh-Hans'|'en-US' }
|
||||
*
|
||||
* iframe → Parent:
|
||||
* { type: 'langbot:api', requestId: string, endpoint: string, method: string, body?: any }
|
||||
*
|
||||
* Parent → iframe (response):
|
||||
* { type: 'langbot:api:response', requestId: string, data?: any, error?: string }
|
||||
*/
|
||||
export default function PluginPagesPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const id = searchParams.get('id');
|
||||
const { t } = useTranslation();
|
||||
const { setDetailEntityName, pluginPages } = useSidebarData();
|
||||
|
||||
// Find the matching page for breadcrumb
|
||||
const page = pluginPages.find((p) => p.id === id);
|
||||
|
||||
useEffect(() => {
|
||||
setDetailEntityName(page?.name ?? id ?? '');
|
||||
return () => setDetailEntityName(null);
|
||||
}, [page, id, setDetailEntityName]);
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('pluginPages.selectFromSidebar')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Parse "author/name/pageId"
|
||||
const parts = id.split('/');
|
||||
if (parts.length < 3) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('pluginPages.invalidPage')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const author = parts[0];
|
||||
const pluginName = parts[1];
|
||||
// Use the asset path from the page manifest, not the page ID
|
||||
const assetPath = page?.path ?? parts.slice(2).join('/');
|
||||
const pageId = parts.slice(2).join('/');
|
||||
|
||||
return (
|
||||
<PluginPageIframe
|
||||
author={author}
|
||||
pluginName={pluginName}
|
||||
pagePath={assetPath}
|
||||
pageId={pageId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginPageIframe({
|
||||
author,
|
||||
pluginName,
|
||||
pagePath,
|
||||
pageId,
|
||||
}: {
|
||||
author: string;
|
||||
pluginName: string;
|
||||
pagePath: string;
|
||||
pageId: string;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
|
||||
|
||||
// Send context (theme + language) to iframe
|
||||
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
|
||||
const sendContext = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'langbot:context',
|
||||
theme: resolvedTheme,
|
||||
language: i18n.language,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}, [resolvedTheme, i18n.language]);
|
||||
|
||||
// Re-send context when theme or language changes
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
sendContext();
|
||||
}
|
||||
}, [resolvedTheme, i18n.language, loading, sendContext]);
|
||||
|
||||
// Handle messages from iframe (API calls)
|
||||
useEffect(() => {
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
// Validate source — only accept messages from our specific iframe window
|
||||
// This is more secure than origin checking: works with sandboxed (null-origin) iframes
|
||||
// and prevents spoofing from other windows/iframes
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
|
||||
// Validate requestId format to prevent injection
|
||||
if (data.type === 'langbot:api') {
|
||||
const { requestId, endpoint, method, body } = data;
|
||||
if (typeof requestId !== 'string' || typeof endpoint !== 'string')
|
||||
return;
|
||||
// Sanitize endpoint — must start with / and not contain ..
|
||||
if (!endpoint.startsWith('/') || endpoint.includes('..')) return;
|
||||
const normalizedMethod =
|
||||
typeof method === 'string' ? method.toUpperCase() : 'POST';
|
||||
if (
|
||||
!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(normalizedMethod)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const result = await httpClient.pluginPageApi(
|
||||
author,
|
||||
pluginName,
|
||||
pageId,
|
||||
endpoint,
|
||||
normalizedMethod,
|
||||
body,
|
||||
);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'langbot:api:response',
|
||||
requestId,
|
||||
data: result,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'langbot:api:response',
|
||||
requestId,
|
||||
error: errorMsg,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [author, pluginName, pageId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={assetUrl}
|
||||
className="flex-1 w-full border-0 rounded-md"
|
||||
style={{ display: loading ? 'none' : 'block' }}
|
||||
onLoad={() => {
|
||||
setLoading(false);
|
||||
sendContext();
|
||||
}}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
title={`${author}/${pluginName} - ${pagePath}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ function StageRow({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300',
|
||||
'flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg transition-all duration-300',
|
||||
isActive &&
|
||||
!isError &&
|
||||
'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
|
||||
@@ -154,7 +154,7 @@ function StageRow({
|
||||
{detail && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-0.5',
|
||||
'text-xs mt-0.5 break-words',
|
||||
isCompleted
|
||||
? 'text-green-600/70 dark:text-green-400/70'
|
||||
: 'text-blue-600/70 dark:text-blue-400/70',
|
||||
@@ -256,7 +256,7 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
<>
|
||||
<span>{parts.join(' · ')}</span>
|
||||
<br />
|
||||
<span className="opacity-70">{currentDep}</span>
|
||||
<span className="opacity-70 break-words">{currentDep}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -277,10 +277,10 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
<div className="space-y-4">
|
||||
{/* Overall progress bar — always blue */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
'text-sm font-medium shrink-0',
|
||||
isDone
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-blue-700 dark:text-blue-300',
|
||||
@@ -360,8 +360,8 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
{/* Done banner */}
|
||||
{isDone && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300 font-medium">
|
||||
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
|
||||
{t('plugins.installProgress.installComplete')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -406,13 +406,13 @@ export default function PluginInstallProgressDialog() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent
|
||||
className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"
|
||||
className="sm:max-w-lg w-[90vw] max-h-[80vh] p-4 sm:p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto overflow-x-hidden"
|
||||
hideCloseButton
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<Download className="size-5" />
|
||||
<span className="truncate">
|
||||
<DialogTitle className="flex items-start gap-3">
|
||||
<Download className="size-5 shrink-0 mt-0.5" />
|
||||
<span className="break-words">
|
||||
{selectedTask
|
||||
? t('plugins.installProgress.title', {
|
||||
name: selectedTask.pluginName,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { TFunction } from 'i18next';
|
||||
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
|
||||
import {
|
||||
Wrench,
|
||||
AudioWaveform,
|
||||
Hash,
|
||||
Book,
|
||||
FileText,
|
||||
PanelTop,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function PluginComponentList({
|
||||
@@ -23,6 +30,7 @@ export default function PluginComponentList({
|
||||
Command: <Hash className="w-5 h-5" />,
|
||||
KnowledgeEngine: <Book className="w-5 h-5" />,
|
||||
Parser: <FileText className="w-5 h-5" />,
|
||||
Page: <PanelTop className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const componentKindList = Object.keys(components || {});
|
||||
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
Search,
|
||||
Wrench,
|
||||
AudioWaveform,
|
||||
Hash,
|
||||
Book,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
FileText,
|
||||
PanelTop,
|
||||
} from 'lucide-react';
|
||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||
@@ -36,7 +39,6 @@ import { toast } from 'sonner';
|
||||
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TagsFilter } from './TagsFilter';
|
||||
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
|
||||
|
||||
import { RecommendationLists, RecommendationList } from './RecommendationLists';
|
||||
@@ -63,6 +65,7 @@ function MarketPageContent({
|
||||
'EventListener',
|
||||
'KnowledgeEngine',
|
||||
'Parser',
|
||||
'Page',
|
||||
];
|
||||
|
||||
const validTypes = ['plugin', 'mcp', 'skill'];
|
||||
@@ -608,7 +611,90 @@ function MarketPageContent({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center justify-end gap-2 lg:w-auto">
|
||||
</div>
|
||||
|
||||
{/* Component filter and sort */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
|
||||
{/* Component filter */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 min-w-0 max-w-full">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{t('market.filterByComponent')}:
|
||||
</span>
|
||||
<div className="overflow-x-auto max-w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
spacing={2}
|
||||
size="sm"
|
||||
value={componentFilter}
|
||||
onValueChange={(value) => {
|
||||
if (value) handleComponentFilterChange(value);
|
||||
}}
|
||||
className="justify-start flex-nowrap"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
value="all"
|
||||
aria-label="All components"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
{t('market.allComponents')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Tool"
|
||||
aria-label="Tool"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Wrench className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Tool')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Command"
|
||||
aria-label="Command"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Hash className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Command')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="EventListener"
|
||||
aria-label="EventListener"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<AudioWaveform className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.EventListener')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="KnowledgeEngine"
|
||||
aria-label="KnowledgeEngine"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<Book className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.KnowledgeEngine')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Parser"
|
||||
aria-label="Parser"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Parser')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Page"
|
||||
aria-label="Page"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<PanelTop className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Page')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||
{t('market.sortBy')}:
|
||||
</span>
|
||||
<Select value={sortOption} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="w-[128px] sm:w-40 text-xs sm:text-sm">
|
||||
<SelectValue />
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Bug,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -464,33 +465,13 @@ function PluginListView() {
|
||||
};
|
||||
|
||||
const handleCopyDebugInfo = (text: string, type: 'url' | 'key') => {
|
||||
try {
|
||||
navigator.clipboard.writeText(text);
|
||||
if (type === 'url') {
|
||||
setCopiedDebugUrl(true);
|
||||
setTimeout(() => setCopiedDebugUrl(false), 2000);
|
||||
} else {
|
||||
setCopiedDebugKey(true);
|
||||
setTimeout(() => setCopiedDebugKey(false), 2000);
|
||||
}
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
textArea.setSelectionRange(0, 99999);
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (success) {
|
||||
setCopiedDebugUrl(true);
|
||||
setTimeout(() => setCopiedDebugUrl(false), 2000);
|
||||
} else {
|
||||
setCopiedDebugKey(true);
|
||||
setTimeout(() => setCopiedDebugKey(false), 2000);
|
||||
}
|
||||
copyToClipboard(text).catch(() => {});
|
||||
if (type === 'url') {
|
||||
setCopiedDebugUrl(true);
|
||||
setTimeout(() => setCopiedDebugUrl(false), 2000);
|
||||
} else {
|
||||
setCopiedDebugKey(true);
|
||||
setTimeout(() => setCopiedDebugKey(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export enum DynamicFormItemType {
|
||||
BOT_SELECTOR = 'bot-selector',
|
||||
TOOLS_SELECTOR = 'tools-selector',
|
||||
WEBHOOK_URL = 'webhook-url',
|
||||
EMBED_CODE = 'embed-code',
|
||||
}
|
||||
|
||||
export interface IFileConfig {
|
||||
|
||||
@@ -31,6 +31,8 @@ export enum PluginV4Status {
|
||||
export interface PluginV4 {
|
||||
id: number;
|
||||
plugin_id: string;
|
||||
mcp_id?: string;
|
||||
skill_id?: string;
|
||||
author: string;
|
||||
name: string;
|
||||
label: I18nObject;
|
||||
|
||||
@@ -117,7 +117,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
|
||||
public scanProviderModels(
|
||||
uuid: string,
|
||||
modelType?: 'llm' | 'embedding',
|
||||
modelType?: 'llm' | 'embedding' | 'rerank',
|
||||
): Promise<ApiRespScannedProviderModels> {
|
||||
const params = modelType ? { type: modelType } : {};
|
||||
return this.get(`/api/v1/provider/providers/${uuid}/scan-models`, params);
|
||||
@@ -608,6 +608,27 @@ export class BackendClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
public async pluginPageApi(
|
||||
author: string,
|
||||
name: string,
|
||||
pageId: string,
|
||||
endpoint: string,
|
||||
method: string = 'POST',
|
||||
body?: unknown,
|
||||
): Promise<unknown> {
|
||||
const resp = await this.instance.request({
|
||||
url: `/api/v1/plugins/${author}/${name}/page-api`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
page_id: pageId,
|
||||
endpoint,
|
||||
method,
|
||||
body,
|
||||
},
|
||||
});
|
||||
return resp.data?.data;
|
||||
}
|
||||
|
||||
public getPluginIconURL(author: string, name: string): string {
|
||||
if (this.instance.defaults.baseURL === '/') {
|
||||
const url = window.location.href;
|
||||
|
||||
39
web/src/app/utils/clipboard.ts
Normal file
39
web/src/app/utils/clipboard.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copy text to clipboard with fallback support
|
||||
* Tries to use modern Clipboard API first, falls back to execCommand if not available
|
||||
*
|
||||
* @param text - The text to copy to clipboard
|
||||
* @returns Promise<boolean> - true if successful, false otherwise
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Try modern Clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Modern API failed, trying fallback:', err);
|
||||
// Fall through to legacy method
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy execCommand method
|
||||
try {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
return successful;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Fallback method failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -572,9 +572,9 @@ export default function WizardPage() {
|
||||
className={cn(
|
||||
'w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center text-xs font-medium transition-colors',
|
||||
idx < currentStep
|
||||
? 'bg-primary text-primary-foreground'
|
||||
? 'bg-blue-600 text-white'
|
||||
: idx === currentStep
|
||||
? 'bg-primary text-primary-foreground'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -588,7 +588,7 @@ export default function WizardPage() {
|
||||
className={cn(
|
||||
'text-sm hidden sm:inline',
|
||||
idx === currentStep
|
||||
? 'font-medium text-foreground'
|
||||
? 'font-medium text-blue-600'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -599,7 +599,7 @@ export default function WizardPage() {
|
||||
<div
|
||||
className={cn(
|
||||
'w-4 sm:w-8 h-px',
|
||||
idx < currentStep ? 'bg-primary' : 'bg-border',
|
||||
idx < currentStep ? 'bg-blue-600' : 'bg-border',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,8 @@ const enUS = {
|
||||
installedPlugins: 'Installed Plugins',
|
||||
pluginMarket: 'Marketplace',
|
||||
mcpServers: 'MCP Servers',
|
||||
pluginPages: 'Plugin Pages',
|
||||
pluginPagesTooltip: 'Visual pages provided by installed plugins',
|
||||
quickStart: 'Quick Start',
|
||||
},
|
||||
common: {
|
||||
@@ -200,6 +202,9 @@ const enUS = {
|
||||
string: 'String',
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
object: 'Object',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'Value must be a valid JSON object',
|
||||
selectModelProvider: 'Select Model Provider',
|
||||
modelProviderDescription:
|
||||
'Please fill in the model name provided by the provider',
|
||||
@@ -503,6 +508,7 @@ const enUS = {
|
||||
Command: 'Command',
|
||||
KnowledgeEngine: 'Knowledge Engine',
|
||||
Parser: 'Parser',
|
||||
Page: 'Page',
|
||||
},
|
||||
uploadLocal: 'Upload Local',
|
||||
debugging: 'Debugging',
|
||||
@@ -1267,6 +1273,41 @@ const enUS = {
|
||||
boxSessionCreated: 'Created',
|
||||
boxSessionLastUsed: 'Last used',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Storage Analysis',
|
||||
description: 'Inspect storage usage and cleanup candidates',
|
||||
openDialog: 'View Analysis',
|
||||
dialogTitle: 'Storage Analysis',
|
||||
generatedAt: 'Generated at {{time}}',
|
||||
loading: 'Loading...',
|
||||
refresh: 'Refresh',
|
||||
totalSize: 'Total size',
|
||||
binaryStorage: 'Binary storage',
|
||||
uploadCleanup: 'Expired uploads',
|
||||
logCleanup: 'Expired logs',
|
||||
sections: 'Storage sections',
|
||||
monitoringTables: 'Monitoring tables',
|
||||
runtimeTasks: 'Runtime tasks',
|
||||
cleanupPolicy: 'Cleanup policy',
|
||||
uploadRetention: 'Upload retention',
|
||||
logRetention: 'Log retention',
|
||||
databaseType: 'Database type',
|
||||
days: 'days',
|
||||
missing: 'Missing',
|
||||
expiredUploads: 'Expired uploads',
|
||||
expiredLogs: 'Expired logs',
|
||||
noExpiredUploads: 'No expired uploaded files',
|
||||
noExpiredLogs: 'No expired log files',
|
||||
sectionNames: {
|
||||
database: 'Database',
|
||||
logs: 'Logs',
|
||||
storage: 'Uploaded files',
|
||||
vector_store: 'Vector store',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
temp: 'Temporary files',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'Maximum number of bots ({{max}}) reached. Please remove an existing bot before creating a new one.',
|
||||
@@ -1423,6 +1464,10 @@ const enUS = {
|
||||
goBack: 'Go Back',
|
||||
backToHome: 'Back to Home',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||
invalidPage: 'Invalid plugin page',
|
||||
},
|
||||
};
|
||||
|
||||
export default enUS;
|
||||
|
||||
@@ -5,6 +5,9 @@ const esES = {
|
||||
installedPlugins: 'Plugins instalados',
|
||||
pluginMarket: 'Tienda',
|
||||
mcpServers: 'Servidores MCP',
|
||||
pluginPages: 'Páginas de plugins',
|
||||
pluginPagesTooltip:
|
||||
'Páginas visuales proporcionadas por los plugins instalados',
|
||||
quickStart: 'Inicio rápido',
|
||||
},
|
||||
common: {
|
||||
@@ -204,6 +207,9 @@ const esES = {
|
||||
string: 'Cadena',
|
||||
number: 'Número',
|
||||
boolean: 'Booleano',
|
||||
object: 'Objeto',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'El valor debe ser un objeto JSON válido',
|
||||
selectModelProvider: 'Seleccionar proveedor del modelo',
|
||||
modelProviderDescription:
|
||||
'Por favor, introduce el nombre del modelo proporcionado por el proveedor',
|
||||
@@ -515,6 +521,7 @@ const esES = {
|
||||
Command: 'Comando',
|
||||
KnowledgeEngine: 'Motor de conocimiento',
|
||||
Parser: 'Analizador',
|
||||
Page: 'Página',
|
||||
},
|
||||
uploadLocal: 'Subir local',
|
||||
debugging: 'Depuración',
|
||||
@@ -1293,6 +1300,42 @@ const esES = {
|
||||
boxSessionCreated: 'Creado',
|
||||
boxSessionLastUsed: 'Último uso',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Análisis de almacenamiento',
|
||||
description:
|
||||
'Inspecciona el uso de almacenamiento y los candidatos de limpieza',
|
||||
openDialog: 'Ver análisis',
|
||||
dialogTitle: 'Análisis de almacenamiento',
|
||||
generatedAt: 'Generado el {{time}}',
|
||||
loading: 'Cargando...',
|
||||
refresh: 'Actualizar',
|
||||
totalSize: 'Tamaño total',
|
||||
binaryStorage: 'Almacenamiento binario de plugins',
|
||||
uploadCleanup: 'Subidas caducadas',
|
||||
logCleanup: 'Registros caducados',
|
||||
sections: 'Secciones de almacenamiento',
|
||||
monitoringTables: 'Tablas de monitoreo',
|
||||
runtimeTasks: 'Tareas en ejecución',
|
||||
cleanupPolicy: 'Política de limpieza',
|
||||
uploadRetention: 'Retención de subidas',
|
||||
logRetention: 'Retención de registros',
|
||||
databaseType: 'Tipo de base de datos',
|
||||
days: 'días',
|
||||
missing: 'Falta',
|
||||
expiredUploads: 'Subidas caducadas',
|
||||
expiredLogs: 'Registros caducados',
|
||||
noExpiredUploads: 'No hay archivos subidos caducados',
|
||||
noExpiredLogs: 'No hay registros caducados',
|
||||
sectionNames: {
|
||||
database: 'Base de datos',
|
||||
logs: 'Registros',
|
||||
storage: 'Archivos subidos',
|
||||
vector_store: 'Almacén vectorial',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
temp: 'Archivos temporales',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'Se ha alcanzado el número máximo de Bots ({{max}}). Por favor, elimina un Bot existente antes de crear uno nuevo.',
|
||||
@@ -1378,6 +1421,10 @@ const esES = {
|
||||
goBack: 'Volver',
|
||||
backToHome: 'Ir al inicio',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||
invalidPage: 'Página de plugin no válida',
|
||||
},
|
||||
};
|
||||
|
||||
export default esES;
|
||||
|
||||
@@ -5,6 +5,8 @@ const jaJP = {
|
||||
installedPlugins: 'インストール済みプラグイン',
|
||||
pluginMarket: 'プラグインマーケット',
|
||||
mcpServers: 'MCPサーバー',
|
||||
pluginPages: 'プラグインページ',
|
||||
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
|
||||
quickStart: 'クイックスタート',
|
||||
},
|
||||
common: {
|
||||
@@ -203,6 +205,9 @@ const jaJP = {
|
||||
string: '文字列',
|
||||
number: '数値',
|
||||
boolean: 'ブール値',
|
||||
object: 'オブジェクト',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: '値は有効なJSONオブジェクトである必要があります',
|
||||
selectModelProvider: 'モデルプロバイダーを選択',
|
||||
modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',
|
||||
modelManufacturer: 'モデルメーカー',
|
||||
@@ -507,6 +512,7 @@ const jaJP = {
|
||||
Command: 'コマンド',
|
||||
KnowledgeEngine: '知識エンジン',
|
||||
Parser: 'パーサー',
|
||||
Page: 'ページ',
|
||||
},
|
||||
uploadLocal: 'ローカルアップロード',
|
||||
debugging: 'デバッグ中',
|
||||
@@ -1265,6 +1271,41 @@ const jaJP = {
|
||||
boxSessionCreated: '作成日時',
|
||||
boxSessionLastUsed: '最終使用',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'ストレージ分析',
|
||||
description: 'ストレージ使用量とクリーンアップ候補を確認します',
|
||||
openDialog: '分析を表示',
|
||||
dialogTitle: 'ストレージ分析',
|
||||
generatedAt: '生成日時 {{time}}',
|
||||
loading: '読み込み中...',
|
||||
refresh: '更新',
|
||||
totalSize: '合計サイズ',
|
||||
binaryStorage: 'プラグインバイナリストレージ',
|
||||
uploadCleanup: '期限切れアップロード',
|
||||
logCleanup: '期限切れログ',
|
||||
sections: 'ストレージセクション',
|
||||
monitoringTables: '監視テーブル',
|
||||
runtimeTasks: '実行タスク',
|
||||
cleanupPolicy: 'クリーンアップポリシー',
|
||||
uploadRetention: 'アップロード保持期間',
|
||||
logRetention: 'ログ保持期間',
|
||||
databaseType: 'データベース種別',
|
||||
days: '日',
|
||||
missing: 'なし',
|
||||
expiredUploads: '期限切れアップロード',
|
||||
expiredLogs: '期限切れログ',
|
||||
noExpiredUploads: '期限切れのアップロードファイルはありません',
|
||||
noExpiredLogs: '期限切れのログファイルはありません',
|
||||
sectionNames: {
|
||||
database: 'データベース',
|
||||
logs: 'ログ',
|
||||
storage: 'アップロードファイル',
|
||||
vector_store: 'ベクターストア',
|
||||
plugins: 'プラグイン',
|
||||
mcp: 'MCP',
|
||||
temp: '一時ファイル',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'ボット数が上限({{max}}個)に達しました。新しいボットを作成するには、既存のボットを削除してください。',
|
||||
@@ -1349,6 +1390,10 @@ const jaJP = {
|
||||
goBack: '戻る',
|
||||
backToHome: 'ホームに戻る',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||
invalidPage: '無効なプラグインページ',
|
||||
},
|
||||
};
|
||||
|
||||
export default jaJP;
|
||||
|
||||
@@ -5,6 +5,9 @@ const ruRU = {
|
||||
installedPlugins: 'Установленные плагины',
|
||||
pluginMarket: 'Маркетплейс',
|
||||
mcpServers: 'MCP-серверы',
|
||||
pluginPages: 'Страницы плагинов',
|
||||
pluginPagesTooltip:
|
||||
'Визуальные страницы, предоставляемые установленными плагинами',
|
||||
quickStart: 'Быстрый старт',
|
||||
},
|
||||
common: {
|
||||
@@ -200,6 +203,9 @@ const ruRU = {
|
||||
string: 'Строка',
|
||||
number: 'Число',
|
||||
boolean: 'Логический',
|
||||
object: 'Объект',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'Значение должно быть допустимым объектом JSON',
|
||||
selectModelProvider: 'Выберите провайдера модели',
|
||||
modelProviderDescription:
|
||||
'Пожалуйста, введите название модели, предоставленное провайдером',
|
||||
@@ -511,6 +517,7 @@ const ruRU = {
|
||||
Command: 'Команда',
|
||||
KnowledgeEngine: 'Движок знаний',
|
||||
Parser: 'Парсер',
|
||||
Page: 'Страница',
|
||||
},
|
||||
uploadLocal: 'Загрузить локально',
|
||||
debugging: 'Отладка',
|
||||
@@ -1267,6 +1274,41 @@ const ruRU = {
|
||||
boxSessionCreated: 'Создано',
|
||||
boxSessionLastUsed: 'Последнее использование',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Анализ хранилища',
|
||||
description: 'Проверьте использование хранилища и кандидатов на очистку',
|
||||
openDialog: 'Открыть анализ',
|
||||
dialogTitle: 'Анализ хранилища',
|
||||
generatedAt: 'Создано {{time}}',
|
||||
loading: 'Загрузка...',
|
||||
refresh: 'Обновить',
|
||||
totalSize: 'Общий размер',
|
||||
binaryStorage: 'Бинарное хранилище плагинов',
|
||||
uploadCleanup: 'Просроченные загрузки',
|
||||
logCleanup: 'Просроченные журналы',
|
||||
sections: 'Разделы хранилища',
|
||||
monitoringTables: 'Таблицы мониторинга',
|
||||
runtimeTasks: 'Задачи runtime',
|
||||
cleanupPolicy: 'Политика очистки',
|
||||
uploadRetention: 'Хранение загрузок',
|
||||
logRetention: 'Хранение журналов',
|
||||
databaseType: 'Тип базы данных',
|
||||
days: 'дн.',
|
||||
missing: 'Нет',
|
||||
expiredUploads: 'Просроченные загрузки',
|
||||
expiredLogs: 'Просроченные журналы',
|
||||
noExpiredUploads: 'Нет просроченных загруженных файлов',
|
||||
noExpiredLogs: 'Нет просроченных журналов',
|
||||
sectionNames: {
|
||||
database: 'База данных',
|
||||
logs: 'Журналы',
|
||||
storage: 'Загруженные файлы',
|
||||
vector_store: 'Векторное хранилище',
|
||||
plugins: 'Плагины',
|
||||
mcp: 'MCP',
|
||||
temp: 'Временные файлы',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'Достигнуто максимальное количество ботов ({{max}}). Удалите существующего бота перед созданием нового.',
|
||||
@@ -1340,6 +1382,10 @@ const ruRU = {
|
||||
backToWorkbench: 'Вернуться к рабочей панели',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||
invalidPage: 'Недопустимая страница плагина',
|
||||
},
|
||||
};
|
||||
|
||||
export default ruRU;
|
||||
|
||||
@@ -5,6 +5,8 @@ const thTH = {
|
||||
installedPlugins: 'ปลั๊กอินที่ติดตั้ง',
|
||||
pluginMarket: 'ตลาดปลั๊กอิน',
|
||||
mcpServers: 'เซิร์ฟเวอร์ MCP',
|
||||
pluginPages: 'หน้าปลั๊กอิน',
|
||||
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
|
||||
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
|
||||
},
|
||||
common: {
|
||||
@@ -198,6 +200,9 @@ const thTH = {
|
||||
string: 'สตริง',
|
||||
number: 'ตัวเลข',
|
||||
boolean: 'บูลีน',
|
||||
object: 'อ็อบเจกต์',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'ค่าต้องเป็นอ็อบเจกต์ JSON ที่ถูกต้อง',
|
||||
selectModelProvider: 'เลือกผู้ให้บริการโมเดล',
|
||||
modelProviderDescription: 'กรุณากรอกชื่อโมเดลที่ผู้ให้บริการจัดเตรียมไว้',
|
||||
modelManufacturer: 'ผู้ผลิตโมเดล',
|
||||
@@ -497,6 +502,7 @@ const thTH = {
|
||||
Command: 'คำสั่ง',
|
||||
KnowledgeEngine: 'เครื่องมือความรู้',
|
||||
Parser: 'ตัวแยกวิเคราะห์',
|
||||
Page: 'หน้า',
|
||||
},
|
||||
uploadLocal: 'อัปโหลดจากเครื่อง',
|
||||
debugging: 'ดีบัก',
|
||||
@@ -1240,6 +1246,41 @@ const thTH = {
|
||||
boxSessionCreated: 'สร้างเมื่อ',
|
||||
boxSessionLastUsed: 'ใช้ล่าสุด',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'วิเคราะห์พื้นที่จัดเก็บ',
|
||||
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
|
||||
openDialog: 'ดูการวิเคราะห์',
|
||||
dialogTitle: 'วิเคราะห์พื้นที่จัดเก็บ',
|
||||
generatedAt: 'สร้างเมื่อ {{time}}',
|
||||
loading: 'กำลังโหลด...',
|
||||
refresh: 'รีเฟรช',
|
||||
totalSize: 'ขนาดรวม',
|
||||
binaryStorage: 'พื้นที่จัดเก็บไบนารีของปลั๊กอิน',
|
||||
uploadCleanup: 'ไฟล์อัปโหลดที่หมดอายุ',
|
||||
logCleanup: 'บันทึกที่หมดอายุ',
|
||||
sections: 'ส่วนพื้นที่จัดเก็บ',
|
||||
monitoringTables: 'ตารางการตรวจสอบ',
|
||||
runtimeTasks: 'งาน runtime',
|
||||
cleanupPolicy: 'นโยบายการล้างข้อมูล',
|
||||
uploadRetention: 'ระยะเวลาเก็บไฟล์อัปโหลด',
|
||||
logRetention: 'ระยะเวลาเก็บบันทึก',
|
||||
databaseType: 'ชนิดฐานข้อมูล',
|
||||
days: 'วัน',
|
||||
missing: 'ไม่มี',
|
||||
expiredUploads: 'ไฟล์อัปโหลดที่หมดอายุ',
|
||||
expiredLogs: 'บันทึกที่หมดอายุ',
|
||||
noExpiredUploads: 'ไม่มีไฟล์อัปโหลดที่หมดอายุ',
|
||||
noExpiredLogs: 'ไม่มีบันทึกที่หมดอายุ',
|
||||
sectionNames: {
|
||||
database: 'ฐานข้อมูล',
|
||||
logs: 'บันทึก',
|
||||
storage: 'ไฟล์อัปโหลด',
|
||||
vector_store: 'คลังเวกเตอร์',
|
||||
plugins: 'ปลั๊กอิน',
|
||||
mcp: 'MCP',
|
||||
temp: 'ไฟล์ชั่วคราว',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'จำนวน Bot สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Bot ที่มีอยู่ก่อนสร้างใหม่',
|
||||
@@ -1320,6 +1361,10 @@ const thTH = {
|
||||
goBack: 'ย้อนกลับ',
|
||||
backToHome: 'กลับหน้าหลัก',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
||||
},
|
||||
};
|
||||
|
||||
export default thTH;
|
||||
|
||||
@@ -5,6 +5,9 @@ const viVN = {
|
||||
installedPlugins: 'Plugin đã cài đặt',
|
||||
pluginMarket: 'Chợ ứng dụng',
|
||||
mcpServers: 'Máy chủ MCP',
|
||||
pluginPages: 'Trang plugin',
|
||||
pluginPagesTooltip:
|
||||
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
|
||||
quickStart: 'Bắt đầu nhanh',
|
||||
},
|
||||
common: {
|
||||
@@ -201,6 +204,9 @@ const viVN = {
|
||||
string: 'Chuỗi',
|
||||
number: 'Số',
|
||||
boolean: 'Boolean',
|
||||
object: 'Đối tượng',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'Giá trị phải là một đối tượng JSON hợp lệ',
|
||||
selectModelProvider: 'Chọn nhà cung cấp mô hình',
|
||||
modelProviderDescription:
|
||||
'Vui lòng điền tên mô hình do nhà cung cấp cung cấp',
|
||||
@@ -507,6 +513,7 @@ const viVN = {
|
||||
Command: 'Lệnh',
|
||||
KnowledgeEngine: 'Công cụ tri thức',
|
||||
Parser: 'Trình phân tích',
|
||||
Page: 'Trang',
|
||||
},
|
||||
uploadLocal: 'Tải lên cục bộ',
|
||||
debugging: 'Gỡ lỗi',
|
||||
@@ -1261,6 +1268,41 @@ const viVN = {
|
||||
boxSessionCreated: 'Đã tạo',
|
||||
boxSessionLastUsed: 'Lần cuối sử dụng',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Phân tích lưu trữ',
|
||||
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
|
||||
openDialog: 'Xem phân tích',
|
||||
dialogTitle: 'Phân tích lưu trữ',
|
||||
generatedAt: 'Tạo lúc {{time}}',
|
||||
loading: 'Đang tải...',
|
||||
refresh: 'Làm mới',
|
||||
totalSize: 'Tổng dung lượng',
|
||||
binaryStorage: 'Lưu trữ nhị phân plugin',
|
||||
uploadCleanup: 'Tệp tải lên hết hạn',
|
||||
logCleanup: 'Nhật ký hết hạn',
|
||||
sections: 'Khu vực lưu trữ',
|
||||
monitoringTables: 'Bảng giám sát',
|
||||
runtimeTasks: 'Tác vụ runtime',
|
||||
cleanupPolicy: 'Chính sách dọn dẹp',
|
||||
uploadRetention: 'Thời gian giữ tệp tải lên',
|
||||
logRetention: 'Thời gian giữ nhật ký',
|
||||
databaseType: 'Loại cơ sở dữ liệu',
|
||||
days: 'ngày',
|
||||
missing: 'Thiếu',
|
||||
expiredUploads: 'Tệp tải lên hết hạn',
|
||||
expiredLogs: 'Nhật ký hết hạn',
|
||||
noExpiredUploads: 'Không có tệp tải lên hết hạn',
|
||||
noExpiredLogs: 'Không có nhật ký hết hạn',
|
||||
sectionNames: {
|
||||
database: 'Cơ sở dữ liệu',
|
||||
logs: 'Nhật ký',
|
||||
storage: 'Tệp tải lên',
|
||||
vector_store: 'Kho vector',
|
||||
plugins: 'Plugin',
|
||||
mcp: 'MCP',
|
||||
temp: 'Tệp tạm',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'Đã đạt số lượng Bot tối đa ({{max}}). Vui lòng xóa một Bot hiện có trước khi tạo mới.',
|
||||
@@ -1342,6 +1384,10 @@ const viVN = {
|
||||
goBack: 'Quay lại',
|
||||
backToHome: 'Về trang chủ',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||
invalidPage: 'Trang plugin không hợp lệ',
|
||||
},
|
||||
};
|
||||
|
||||
export default viVN;
|
||||
|
||||
@@ -5,6 +5,8 @@ const zhHans = {
|
||||
installedPlugins: '已安装插件',
|
||||
pluginMarket: '插件市场',
|
||||
mcpServers: 'MCP 服务器',
|
||||
pluginPages: '插件页面',
|
||||
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
|
||||
quickStart: '快速开始向导',
|
||||
},
|
||||
common: {
|
||||
@@ -192,6 +194,9 @@ const zhHans = {
|
||||
string: '字符串',
|
||||
number: '数字',
|
||||
boolean: '布尔值',
|
||||
object: '对象',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: '值必须是有效的 JSON 对象',
|
||||
selectModelProvider: '选择模型供应商',
|
||||
modelProviderDescription: '请填写供应商向您提供的模型名称',
|
||||
modelManufacturer: '模型厂商',
|
||||
@@ -480,6 +485,7 @@ const zhHans = {
|
||||
Command: '命令',
|
||||
KnowledgeEngine: '知识引擎',
|
||||
Parser: '解析器',
|
||||
Page: '页面',
|
||||
},
|
||||
uploadLocal: '本地上传',
|
||||
debugging: '调试中',
|
||||
@@ -1213,6 +1219,41 @@ const zhHans = {
|
||||
boxSessionCreated: '创建时间',
|
||||
boxSessionLastUsed: '最后使用',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '存储分析',
|
||||
description: '查看存储占用和可清理文件',
|
||||
openDialog: '查看分析',
|
||||
dialogTitle: '存储分析',
|
||||
generatedAt: '生成时间 {{time}}',
|
||||
loading: '加载中...',
|
||||
refresh: '刷新',
|
||||
totalSize: '总占用',
|
||||
binaryStorage: '插件二进制存储',
|
||||
uploadCleanup: '过期上传文件',
|
||||
logCleanup: '过期日志',
|
||||
sections: '存储分区',
|
||||
monitoringTables: '监控表',
|
||||
runtimeTasks: '运行任务',
|
||||
cleanupPolicy: '清理策略',
|
||||
uploadRetention: '上传文件保留',
|
||||
logRetention: '日志保留',
|
||||
databaseType: '数据库类型',
|
||||
days: '天',
|
||||
missing: '不存在',
|
||||
expiredUploads: '过期上传文件',
|
||||
expiredLogs: '过期日志',
|
||||
noExpiredUploads: '暂无过期上传文件',
|
||||
noExpiredLogs: '暂无过期日志',
|
||||
sectionNames: {
|
||||
database: '数据库',
|
||||
logs: '日志',
|
||||
storage: '上传文件',
|
||||
vector_store: '向量库',
|
||||
plugins: '插件',
|
||||
mcp: 'MCP',
|
||||
temp: '临时文件',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'已达到机器人数量上限({{max}}个)。请先删除已有机器人后再创建新的。',
|
||||
@@ -1360,6 +1401,10 @@ const zhHans = {
|
||||
goBack: '返回上页',
|
||||
backToHome: '返回首页',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||
invalidPage: '无效的插件页面',
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHans;
|
||||
|
||||
@@ -5,6 +5,8 @@ const zhHant = {
|
||||
installedPlugins: '已安裝外掛',
|
||||
pluginMarket: '外掛市場',
|
||||
mcpServers: 'MCP 伺服器',
|
||||
pluginPages: '插件頁面',
|
||||
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
|
||||
quickStart: '快速開始',
|
||||
},
|
||||
common: {
|
||||
@@ -192,6 +194,9 @@ const zhHant = {
|
||||
string: '字串',
|
||||
number: '數字',
|
||||
boolean: '布林值',
|
||||
object: '物件',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: '值必須是有效的 JSON 物件',
|
||||
selectModelProvider: '選擇模型供應商',
|
||||
modelProviderDescription: '請填寫供應商向您提供的模型名稱',
|
||||
modelManufacturer: '模型廠商',
|
||||
@@ -481,6 +486,7 @@ const zhHant = {
|
||||
Command: '命令',
|
||||
KnowledgeEngine: '知識引擎',
|
||||
Parser: '解析器',
|
||||
Page: '擴展頁',
|
||||
},
|
||||
uploadLocal: '本地上傳',
|
||||
debugging: '調試中',
|
||||
@@ -1206,6 +1212,41 @@ const zhHant = {
|
||||
boxSessionCreated: '建立時間',
|
||||
boxSessionLastUsed: '最後使用',
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '儲存分析',
|
||||
description: '查看儲存占用和可清理檔案',
|
||||
openDialog: '查看分析',
|
||||
dialogTitle: '儲存分析',
|
||||
generatedAt: '生成時間 {{time}}',
|
||||
loading: '載入中...',
|
||||
refresh: '重新整理',
|
||||
totalSize: '總占用',
|
||||
binaryStorage: '插件二進位儲存',
|
||||
uploadCleanup: '過期上傳檔案',
|
||||
logCleanup: '過期日誌',
|
||||
sections: '儲存分區',
|
||||
monitoringTables: '監控表',
|
||||
runtimeTasks: '執行任務',
|
||||
cleanupPolicy: '清理策略',
|
||||
uploadRetention: '上傳檔案保留',
|
||||
logRetention: '日誌保留',
|
||||
databaseType: '資料庫類型',
|
||||
days: '天',
|
||||
missing: '不存在',
|
||||
expiredUploads: '過期上傳檔案',
|
||||
expiredLogs: '過期日誌',
|
||||
noExpiredUploads: '暫無過期上傳檔案',
|
||||
noExpiredLogs: '暫無過期日誌',
|
||||
sectionNames: {
|
||||
database: '資料庫',
|
||||
logs: '日誌',
|
||||
storage: '上傳檔案',
|
||||
vector_store: '向量庫',
|
||||
plugins: '插件',
|
||||
mcp: 'MCP',
|
||||
temp: '暫存檔案',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'已達到機器人數量上限({{max}}個)。請先刪除已有機器人後再建立新的。',
|
||||
@@ -1282,6 +1323,10 @@ const zhHant = {
|
||||
goBack: '返回上頁',
|
||||
backToHome: '返回首頁',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||
invalidPage: '無效的插件頁面',
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHant;
|
||||
|
||||
@@ -23,6 +23,7 @@ import MCPPage from '@/app/home/mcp/page';
|
||||
import KnowledgePage from '@/app/home/knowledge/page';
|
||||
import SkillsPage from '@/app/home/skills/page';
|
||||
import ErrorPage from '@/components/ErrorPage';
|
||||
import PluginPagesPage from '@/app/home/plugin-pages/page';
|
||||
|
||||
const Loading = () => <div>Loading...</div>;
|
||||
|
||||
@@ -156,6 +157,16 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/home/plugin-pages',
|
||||
element: (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<HomeLayout>
|
||||
<PluginPagesPage />
|
||||
</HomeLayout>
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user