merge: resolve conflicts with master, add inbound attachment materialization

- Delete localagent.py and test_difysvapi_runner.py (replaced by plugins)
- Keep master's tool loader enhancements (byte_offset, encoding params)
- Remove feature branch's artifact reference code (use sandbox paths instead)
- Add _materialize_inbound_attachments in orchestrator for sandbox file staging
- Keep master's test formatting and new tests
- Keep master's frontend refactoring
This commit is contained in:
huanghuoguoguo
2026-06-19 10:51:00 +08:00
149 changed files with 6721 additions and 3197 deletions
+2
View File
@@ -12,6 +12,8 @@
# testing
/coverage
/playwright-report
/test-results
# next.js
/dist/
+10
View File
@@ -1,3 +1,13 @@
# Debug LangBot Frontend
Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information.
## Tests
Run the frontend smoke tests without a backend process:
```bash
pnpm test:e2e
```
The Playwright suite starts Vite and mocks the LangBot backend and Space APIs.
+2
View File
@@ -6,6 +6,7 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test:e2e": "playwright test",
"lint": "eslint .",
"format": "prettier --write ."
},
@@ -86,6 +87,7 @@
"zod": "^3.24.4"
},
"devDependencies": {
"@playwright/test": "^1.61.0",
"@types/debug": "^4.1.12",
"@types/estree": "^1.0.8",
"@types/estree-jsx": "^1.0.5",
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['github'], ['list']] : 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm exec vite --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
+35
View File
@@ -192,6 +192,9 @@ dependencies:
version: 3.25.76
devDependencies:
'@playwright/test':
specifier: ^1.61.0
version: 1.61.0
'@types/debug':
specifier: ^4.1.12
version: 4.1.12
@@ -529,6 +532,14 @@ packages:
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
dev: true
/@playwright/test@1.61.0:
resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==}
engines: {node: '>=18'}
hasBin: true
dependencies:
playwright: 1.61.0
dev: true
/@radix-ui/number@1.1.1:
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
dev: false
@@ -3204,6 +3215,14 @@ packages:
engines: {node: '>=0.4.x'}
dev: false
/fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4940,6 +4959,22 @@ packages:
hasBin: true
dev: true
/playwright-core@1.61.0:
resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==}
engines: {node: '>=18'}
hasBin: true
dev: true
/playwright@1.61.0:
resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==}
engines: {node: '>=18'}
hasBin: true
dependencies:
playwright-core: 1.61.0
optionalDependencies:
fsevents: 2.3.2
dev: true
/pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
@@ -48,7 +48,6 @@ interface PipelineOption {
}
interface RoutingRulesEditorProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: UseFormReturn<any>;
pipelineNameList: PipelineOption[];
}
@@ -1,181 +0,0 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
interface AccountSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function AccountSettingsDialog({
open,
onOpenChange,
}: AccountSettingsDialogProps) {
const { t } = useTranslation();
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [hasPassword, setHasPassword] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
useEffect(() => {
if (open) {
loadUserInfo();
}
}, [open]);
async function loadUserInfo() {
setLoading(true);
try {
const info = await httpClient.getUserInfo();
setAccountType(info.account_type);
setHasPassword(info.has_password);
setUserEmail(info.user);
} catch {
toast.error(t('common.error'));
} finally {
setLoading(false);
}
}
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
setSpaceBindLoading(false);
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
// Pass token as state for security verification
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
setSpaceBindLoading(false);
}
};
const handlePasswordDialogClose = (dialogOpen: boolean) => {
setPasswordDialogOpen(dialogOpen);
if (!dialogOpen) {
// Reload user info to update password status
loadUserInfo();
}
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('account.settings')}</DialogTitle>
<DialogDescription>{userEmail}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
{/* Password Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<KeyRound className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<ItemDescription>
{hasPassword
? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={() => setPasswordDialogOpen(true)}
disabled={!systemInfo.allow_modify_login_info}
>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</Button>
</ItemActions>
</Item>
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<Layers className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
<ItemDescription>
{accountType === 'space'
? t('account.spaceBoundDescription')
: t('account.bindSpaceDescription')}
</ItemDescription>
</ItemContent>
{accountType === 'local' && (
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={handleBindSpace}
disabled={
spaceBindLoading || !systemInfo.allow_modify_login_info
}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button>
</ItemActions>
)}
</Item>
</div>
)}
</DialogContent>
</Dialog>
<PasswordChangeDialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</>
);
}
@@ -0,0 +1,171 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
Item,
ItemMedia,
ItemContent,
ItemTitle,
ItemDescription,
ItemActions,
} from '@/components/ui/item';
import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http';
import { Loader2, ExternalLink, KeyRound, Layers } from 'lucide-react';
import PasswordChangeDialog from '../password-change-dialog/PasswordChangeDialog';
import { PanelBody } from '../settings-dialog/panel-layout';
interface AccountSettingsPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
onEmailResolved?: (email: string) => void;
}
export default function AccountSettingsPanel({
active,
onEmailResolved,
}: AccountSettingsPanelProps) {
const { t } = useTranslation();
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [hasPassword, setHasPassword] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [loading, setLoading] = useState(true);
const [spaceBindLoading, setSpaceBindLoading] = useState(false);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
useEffect(() => {
if (active) {
loadUserInfo();
}
}, [active]);
async function loadUserInfo() {
setLoading(true);
try {
const info = await httpClient.getUserInfo();
setAccountType(info.account_type);
setHasPassword(info.has_password);
setUserEmail(info.user);
onEmailResolved?.(info.user);
} catch {
toast.error(t('common.error'));
} finally {
setLoading(false);
}
}
const handleBindSpace = async () => {
setSpaceBindLoading(true);
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
setSpaceBindLoading(false);
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
// Pass token as state for security verification
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
setSpaceBindLoading(false);
}
};
const handlePasswordDialogClose = (dialogOpen: boolean) => {
setPasswordDialogOpen(dialogOpen);
if (!dialogOpen) {
// Reload user info to update password status
loadUserInfo();
}
};
return (
<PanelBody>
{userEmail && (
<p className="mb-4 text-sm text-muted-foreground">{userEmail}</p>
)}
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
{/* Password Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<KeyRound className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.passwordStatus')}</ItemTitle>
<ItemDescription>
{hasPassword
? t('account.passwordSetDescription')
: t('account.setPasswordHint')}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={() => setPasswordDialogOpen(true)}
disabled={!systemInfo.allow_modify_login_info}
>
{hasPassword
? t('common.changePassword')
: t('account.setPassword')}
</Button>
</ItemActions>
</Item>
{/* Space Account Item */}
<Item size="sm" variant="muted" className="rounded-lg">
<ItemMedia variant="icon">
<Layers className="h-4 w-4" />
</ItemMedia>
<ItemContent>
<ItemTitle>{t('account.spaceStatus')}</ItemTitle>
<ItemDescription>
{accountType === 'space'
? t('account.spaceBoundDescription')
: t('account.bindSpaceDescription')}
</ItemDescription>
</ItemContent>
{accountType === 'local' && (
<ItemActions>
<Button
variant="outline"
size="sm"
onClick={handleBindSpace}
disabled={
spaceBindLoading || !systemInfo.allow_modify_login_info
}
>
{spaceBindLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ExternalLink className="mr-2 h-4 w-4" />
)}
{t('account.bindSpaceButton')}
</Button>
</ItemActions>
)}
</Item>
</div>
)}
<PasswordChangeDialog
open={passwordDialogOpen}
onOpenChange={handlePasswordDialogClose}
hasPassword={hasPassword}
/>
</PanelBody>
);
}
@@ -3,7 +3,6 @@ import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react';
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import {
Dialog,
DialogContent,
@@ -37,6 +36,7 @@ import {
} from '@/components/ui/alert-dialog';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
interface ApiKey {
id: number;
@@ -55,20 +55,15 @@ interface Webhook {
created_at: string;
}
interface ApiIntegrationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interface ApiIntegrationPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
}
export default function ApiIntegrationDialog({
open,
onOpenChange,
}: ApiIntegrationDialogProps) {
export default function ApiIntegrationPanel({
active,
}: ApiIntegrationPanelProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState('apikeys');
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
@@ -91,33 +86,7 @@ export default function ApiIntegrationDialog({
);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Sync URL with dialog state
useEffect(() => {
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showApiIntegrationSettings');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
}
}, [open]);
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen && (deleteKeyId || deleteWebhookId)) {
return;
}
if (!newOpen) {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
onOpenChange(newOpen);
};
// 清理 body 样式,防止对话框关闭后页面无法交互
// 清理 body 样式,防止嵌套对话框关闭后页面无法交互
useEffect(() => {
if (!deleteKeyId && !deleteWebhookId) {
const cleanup = () => {
@@ -131,11 +100,11 @@ export default function ApiIntegrationDialog({
}, [deleteKeyId, deleteWebhookId]);
useEffect(() => {
if (open) {
if (active) {
loadApiKeys();
loadWebhooks();
}
}, [open]);
}, [active]);
const loadApiKeys = async () => {
setLoading(true);
@@ -284,233 +253,209 @@ export default function ApiIntegrationDialog({
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[800px] h-[26rem] flex flex-col">
<DialogHeader>
<DialogTitle>{t('common.manageApiIntegration')}</DialogTitle>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full flex-1 flex flex-col overflow-hidden"
>
<TabsList className="shadow-md py-3 bg-[#f0f0f0] dark:bg-[#2a2a2e]">
<TabsTrigger className="px-5 py-4 cursor-pointer" value="apikeys">
{t('common.apiKeys')}
</TabsTrigger>
<TabsTrigger
className="px-5 py-4 cursor-pointer"
value="webhooks"
>
{t('common.webhooks')}
</TabsTrigger>
</TabsList>
{/* API Keys Tab */}
<TabsContent
value="apikeys"
className="space-y-4 flex-1 flex flex-col overflow-hidden"
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex h-full min-h-0 w-full flex-col overflow-hidden"
>
<PanelToolbar>
<TabsList>
<TabsTrigger value="apikeys">{t('common.apiKeys')}</TabsTrigger>
<TabsTrigger value="webhooks">{t('common.webhooks')}</TabsTrigger>
</TabsList>
{activeTab === 'apikeys' ? (
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="border rounded-md overflow-auto flex-1">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{item.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* Webhooks Tab */}
<TabsContent
value="webhooks"
className="space-y-4 flex-1 flex flex-col overflow-hidden"
>
<div className="flex items-start gap-2 text-sm text-muted-foreground">
{t('common.webhookHint')}
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
</div>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="border rounded-md overflow-auto flex-1 max-w-full">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() =>
handleToggleWebhook(webhook)
}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('common.close')}
<Plus className="h-4 w-4" />
{t('common.createApiKey')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
) : (
<Button
onClick={() => setShowCreateWebhookDialog(true)}
size="sm"
className="gap-2"
>
<Plus className="h-4 w-4" />
{t('common.createWebhook')}
</Button>
)}
</PanelToolbar>
{/* API Keys Tab */}
<TabsContent
value="apikeys"
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
>
<p className="text-sm text-muted-foreground">
{t('common.apiKeyHint')}
</p>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : apiKeys.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noApiKeys')}
</div>
) : (
<div className="flex-1 overflow-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px]">
{t('common.name')}
</TableHead>
<TableHead className="min-w-[200px]">
{t('common.apiKeyValue')}
</TableHead>
<TableHead className="w-[100px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{item.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
{/* Webhooks Tab */}
<TabsContent
value="webhooks"
className="min-h-0 flex-1 space-y-4 overflow-auto px-6 py-5"
>
<p className="text-sm text-muted-foreground">
{t('common.webhookHint')}
</p>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.loading')}
</div>
) : webhooks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t('common.noWebhooks')}
</div>
) : (
<div className="max-w-full flex-1 overflow-auto rounded-md border">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow>
<TableHead className="w-[150px]">
{t('common.name')}
</TableHead>
<TableHead className="w-[380px]">
{t('common.webhookUrl')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.webhookEnabled')}
</TableHead>
<TableHead className="w-[80px]">
{t('common.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{webhooks.map((webhook) => (
<TableRow key={webhook.id}>
<TableCell className="truncate">
<div className="truncate">
<div
className="font-medium truncate"
title={webhook.name}
>
{webhook.name}
</div>
{webhook.description && (
<div
className="text-sm text-muted-foreground truncate"
title={webhook.description}
>
{webhook.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="overflow-x-auto max-w-[380px]">
<code className="text-sm bg-muted px-2 py-1 rounded whitespace-nowrap inline-block">
{webhook.url}
</code>
</div>
</TableCell>
<TableCell>
<Switch
checked={webhook.enabled}
onCheckedChange={() => handleToggleWebhook(webhook)}
/>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteWebhookId(webhook.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</TabsContent>
</Tabs>
{/* Create API Key Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
@@ -61,7 +61,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
import SettingsDialog, {
SettingsSection,
} from '@/app/home/components/settings-dialog/SettingsDialog';
export default function DynamicFormItemComponent({
config,
@@ -87,6 +89,8 @@ export default function DynamicFormItemComponent({
);
const { t } = useTranslation();
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
const fetchLlmModels = () => {
httpClient
@@ -586,9 +590,11 @@ export default function DynamicFormItemComponent({
</TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip>
<ModelsDialog
<SettingsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
section={settingsSection}
onSectionChange={setSettingsSection}
/>
</div>
);
@@ -938,9 +944,11 @@ export default function DynamicFormItemComponent({
{t('models.title')}
</TooltipContent>
</Tooltip>
<ModelsDialog
<SettingsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
section={settingsSection}
onSectionChange={setSettingsSection}
/>
</div>
</div>
@@ -59,7 +59,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
export function getDefaultValues(
itemConfigList: IDynamicFormItemSchema[],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Record<string, any> {
return itemConfigList.reduce(
(acc, item) => {
@@ -71,7 +70,7 @@ export function getDefaultValues(
acc[item.name] = item.default;
return acc;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{} as Record<string, any>,
);
}
@@ -57,11 +57,12 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { LanguageSelector } from '@/components/ui/language-selector';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import AccountSettingsDialog from '@/app/home/components/account-settings-dialog/AccountSettingsDialog';
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 SettingsDialog, {
SettingsSection,
SETTINGS_ACTION_BY_SECTION,
SETTINGS_SECTION_BY_ACTION,
} from '@/app/home/components/settings-dialog/SettingsDialog';
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { toast } from 'sonner';
@@ -1548,17 +1549,10 @@ export default function HomeSidebar({
}, [pathname]);
useEffect(() => {
if (searchParams.get('action') === 'showModelSettings') {
setModelsDialogOpen(true);
}
if (searchParams.get('action') === 'showAccountSettings') {
setAccountSettingsOpen(true);
}
if (searchParams.get('action') === 'showApiIntegrationSettings') {
setApiKeyDialogOpen(true);
}
if (searchParams.get('action') === 'showStorageAnalysis') {
setStorageAnalysisOpen(true);
const action = searchParams.get('action');
if (action && SETTINGS_SECTION_BY_ACTION[action]) {
setSettingsSection(SETTINGS_SECTION_BY_ACTION[action]);
setSettingsOpen(true);
}
}, [searchParams]);
@@ -1567,15 +1561,14 @@ export default function HomeSidebar({
useState<Record<string, boolean>>(loadSectionState);
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
const [latestRelease, setLatestRelease] = useState<GitHubRelease | null>(
null,
);
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);
@@ -1600,51 +1593,28 @@ export default function HomeSidebar({
setShowScrollHint(false);
}, 250);
}
function handleModelsDialogChange(open: boolean) {
setModelsDialogOpen(open);
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showModelSettings');
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 });
}
function openSettings(section: SettingsSection) {
setSettingsSection(section);
setSettingsOpen(true);
const params = new URLSearchParams(searchParams.toString());
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
}
function handleAccountSettingsChange(open: boolean) {
setAccountSettingsOpen(open);
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showAccountSettings');
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 });
}
function handleSettingsSectionChange(section: SettingsSection) {
setSettingsSection(section);
const params = new URLSearchParams(searchParams.toString());
params.set('action', SETTINGS_ACTION_BY_SECTION[section]);
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
}
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 {
function handleSettingsOpenChange(open: boolean) {
setSettingsOpen(open);
if (!open) {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
@@ -1913,24 +1883,11 @@ export default function HomeSidebar({
{/* Footer */}
<SidebarFooter>
{/* API Integration entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setApiKeyDialogOpen(true)}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4 text-blue-500" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* Models entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => handleModelsDialogChange(true)}
onClick={() => openSettings('models')}
tooltip={t('models.title')}
>
<Sparkles className="text-blue-500" />
@@ -1939,6 +1896,19 @@ export default function HomeSidebar({
</SidebarMenuItem>
</SidebarMenu>
{/* API Integration entry */}
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => openSettings('apiIntegration')}
tooltip={t('common.apiIntegration')}
>
<KeyRound className="size-4 text-blue-500" />
<span>{t('common.apiIntegration')}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
<SidebarMenu>
<SidebarMenuItem>
@@ -2018,7 +1988,10 @@ export default function HomeSidebar({
{/* Account actions */}
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => handleAccountSettingsChange(true)}
onClick={() => {
setUserMenuOpen(false);
openSettings('account');
}}
>
<Settings />
{t('account.settings')}
@@ -2026,7 +1999,7 @@ export default function HomeSidebar({
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
handleStorageAnalysisChange(true);
openSettings('storageAnalysis');
}}
>
<HardDrive />
@@ -2123,27 +2096,17 @@ export default function HomeSidebar({
</SidebarFooter>
</Sidebar>
<AccountSettingsDialog
open={accountSettingsOpen}
onOpenChange={handleAccountSettingsChange}
/>
<ApiIntegrationDialog
open={apiKeyDialogOpen}
onOpenChange={setApiKeyDialogOpen}
<SettingsDialog
open={settingsOpen}
onOpenChange={handleSettingsOpenChange}
section={settingsSection}
onSectionChange={handleSettingsSectionChange}
/>
<NewVersionDialog
open={versionDialogOpen}
onOpenChange={setVersionDialogOpen}
release={latestRelease}
/>
<ModelsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
<StorageAnalysisDialog
open={storageAnalysisOpen}
onOpenChange={handleStorageAnalysisChange}
/>
</>
);
}
@@ -23,10 +23,13 @@ import {
LANGBOT_MODELS_PROVIDER_REQUESTER,
} from './types';
import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
interface ModelsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
// Notify parent when a nested modal (provider form) should block outer-close.
onBlockingChange?: (blocking: boolean) => void;
}
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
@@ -75,10 +78,10 @@ function parseContextLength(
return value;
}
export default function ModelsDialog({
open,
onOpenChange,
}: ModelsDialogProps) {
export default function ModelsPanel({
active,
onBlockingChange,
}: ModelsPanelProps) {
const { t } = useTranslation();
const [providers, setProviders] = useState<ModelProvider[]>([]);
@@ -136,12 +139,17 @@ export default function ModelsDialog({
);
useEffect(() => {
if (open) {
if (active) {
loadUserInfo();
loadProviders();
loadRequesterSupportTypes();
}
}, [open]);
}, [active]);
// Notify parent of blocking state so it can guard outer-close.
useEffect(() => {
onBlockingChange?.(providerFormOpen);
}, [providerFormOpen, onBlockingChange]);
// Auto-expand LangBot Models when no external providers exist
useEffect(() => {
@@ -604,57 +612,38 @@ export default function ModelsDialog({
return (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && providerFormOpen) return;
onOpenChange(newOpen);
}}
>
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
<DialogTitle>{t('models.title')}</DialogTitle>
</DialogHeader>
<PanelBody>
{/* LangBot Models (Space) provider card is intentionally pinned to the
top, above the "add custom provider" action row. */}
{langbotProvider && renderProviderCard(langbotProvider, true)}
<div className="flex-1 overflow-auto px-6 pb-6 mt-0">
{/* LangBot Models Card */}
{langbotProvider && renderProviderCard(langbotProvider, true)}
{/* Add-provider row: stays below the pinned card by design. */}
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
{otherProviders.length === 0
? t(
systemInfo.disable_models_service
? 'models.addProviderHintSimple'
: 'models.addProviderHint',
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
{/* Add Provider Button */}
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
<span className="text-sm text-muted-foreground">
{otherProviders.length === 0
? t(
systemInfo.disable_models_service
? 'models.addProviderHintSimple'
: 'models.addProviderHint',
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleCreateProvider}
>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
</div>
{/* Provider List */}
{otherProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{t('models.noProviders')}</p>
</div>
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
{/* Provider List */}
{otherProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{t('models.noProviders')}</p>
</div>
</DialogContent>
</Dialog>
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
</PanelBody>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-[600px] p-6">
@@ -0,0 +1,229 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from '@/components/ui/sidebar';
import { cn } from '@/lib/utils';
import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/AccountSettingsPanel';
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
// The set of settings sections shown in the unified dialog. The string values
// are also reused as the ?action= query param suffix so deep links keep working.
export type SettingsSection =
| 'account'
| 'apiIntegration'
| 'models'
| 'storageAnalysis';
// Map between a section id and its ?action= query value, so existing deep links
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
// showStorageAnalysis) continue to resolve to the right section.
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
account: 'showAccountSettings',
apiIntegration: 'showApiIntegrationSettings',
models: 'showModelSettings',
storageAnalysis: 'showStorageAnalysis',
};
export const SETTINGS_SECTION_BY_ACTION: Record<string, SettingsSection> =
Object.fromEntries(
Object.entries(SETTINGS_ACTION_BY_SECTION).map(([section, action]) => [
action,
section as SettingsSection,
]),
);
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
section: SettingsSection;
onSectionChange: (section: SettingsSection) => void;
}
export default function SettingsDialog({
open,
onOpenChange,
section,
onSectionChange,
}: SettingsDialogProps) {
const { t } = useTranslation();
// A nested modal (e.g. the provider form) can request that we ignore
// outer-close until it is dismissed.
const [blocking, setBlocking] = useState(false);
// Only the Models panel can raise a blocking nested modal. When we navigate
// away from it (or close the dialog) the panel unmounts without resetting,
// so clear the flag here to avoid getting stuck unable to close.
useEffect(() => {
if (section !== 'models' || !open) {
setBlocking(false);
}
}, [section, open]);
const navItems: {
id: SettingsSection;
label: string;
title: string;
description: string;
icon: React.ReactNode;
}[] = [
{
id: 'models',
label: t('settingsDialog.nav.models'),
title: t('models.title'),
description: t('models.description'),
icon: <Sparkles className="size-4" />,
},
{
id: 'apiIntegration',
label: t('settingsDialog.nav.api'),
title: t('common.apiIntegration'),
description: t('common.apiIntegrationDescription'),
icon: <KeyRound className="size-4" />,
},
{
id: 'storageAnalysis',
label: t('settingsDialog.nav.storage'),
title: t('storageAnalysis.title'),
description: t('storageAnalysis.description'),
icon: <HardDrive className="size-4" />,
},
{
id: 'account',
label: t('settingsDialog.nav.account'),
title: t('account.settings'),
description: t('account.settingsDescription'),
icon: <Settings className="size-4" />,
},
];
const activeItem = navItems.find((item) => item.id === section);
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
return (
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && blocking) return;
onOpenChange(newOpen);
}}
>
<DialogContent
className="h-[80vh] max-h-[800px] overflow-hidden p-0 sm:max-w-[52rem] [&>button:last-child]:z-20"
// Fixed height so switching sections never resizes the dialog; each
// panel scrolls its own content internally.
>
<DialogTitle className="sr-only">
{t('settingsDialog.title')}
</DialogTitle>
<DialogDescription className="sr-only">{activeLabel}</DialogDescription>
{/* Override the SidebarProvider wrapper's default h-svh so the two
columns fill the dialog's fixed height instead of the viewport. */}
<SidebarProvider className="!min-h-0 h-full">
<Sidebar
collapsible="none"
className="hidden h-full w-44 shrink-0 border-r md:flex"
>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<div className="px-2 py-3 text-sm font-semibold">
{t('settingsDialog.title')}
</div>
<SidebarMenu>
{navItems.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
isActive={section === item.id}
onClick={() => onSectionChange(item.id)}
>
{item.icon}
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
<main className="flex h-full min-w-0 flex-1 flex-col overflow-hidden">
{/* Mobile section switcher (sidebar is hidden on small screens) */}
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b px-3 py-2 md:hidden">
{navItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onSectionChange(item.id)}
className={cn(
'flex items-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm',
section === item.id
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground',
)}
>
{item.icon}
<span>{item.label}</span>
</button>
))}
</div>
{/* Unified section header (shared across all tabs). The extra
right padding keeps the title clear of the dialog's close X. */}
<div className="flex shrink-0 flex-col gap-0.5 border-b px-6 py-4 pr-12">
<h2 className="flex items-center gap-2 text-base font-semibold">
{activeItem?.icon}
{activeItem?.title}
</h2>
{activeItem?.description && (
<p className="text-sm text-muted-foreground">
{activeItem.description}
</p>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{section === 'models' && (
<ModelsPanel
active={open && section === 'models'}
onBlockingChange={setBlocking}
/>
)}
{section === 'apiIntegration' && (
<ApiIntegrationPanel
active={open && section === 'apiIntegration'}
/>
)}
{section === 'storageAnalysis' && (
<StorageAnalysisPanel
active={open && section === 'storageAnalysis'}
/>
)}
{section === 'account' && (
<AccountSettingsPanel active={open && section === 'account'} />
)}
</div>
</main>
</SidebarProvider>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,45 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Shared layout primitives for the settings-dialog panels.
*
* Every section renders under the dialog's unified header, so the panels
* themselves should share the same vertical rhythm: an optional top toolbar
* (meta on the left, primary action on the right) followed by a scrollable
* body with consistent padding. Keeping these in one place is what makes the
* tabs feel like one cohesive surface instead of four separately-styled views.
*/
export function PanelToolbar({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
className={cn(
'flex shrink-0 items-center justify-between gap-3 border-b px-6 py-3',
className,
)}
>
{children}
</div>
);
}
export function PanelBody({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn('min-h-0 flex-1 overflow-auto px-6 py-5', className)}>
{children}
</div>
);
}
@@ -1,410 +0,0 @@
'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>
);
}
@@ -0,0 +1,391 @@
'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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
import { PanelToolbar } from '../settings-dialog/panel-layout';
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 StorageAnalysisPanelProps {
// True when this panel is the active section and the dialog is open.
active: boolean;
}
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 StorageAnalysisPanel({
active,
}: StorageAnalysisPanelProps) {
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 (active) {
loadAnalysis();
}
}, [loadAnalysis, active]);
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 (
<div className="flex h-full min-h-0 flex-col">
<PanelToolbar>
<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>
</PanelToolbar>
<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>
</div>
);
}
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>
);
}
@@ -310,6 +310,7 @@ function SingleSelectField({
{options.map((opt) => (
<div key={opt.id}>
<button
type="button"
onClick={() => onChange(opt.id)}
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors ${
value === opt.id
@@ -361,8 +362,16 @@ function MultiSelectField({
const selected = value.includes(opt.id);
return (
<div key={opt.id}>
<button
<div
role="button"
tabIndex={0}
onClick={() => toggle(opt.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle(opt.id);
}
}}
className={`w-full text-left text-sm px-3 py-2 rounded-lg border transition-colors flex items-center gap-2 ${
selected
? 'border-primary bg-primary/5 text-primary'
@@ -371,7 +380,7 @@ function MultiSelectField({
>
<Checkbox checked={selected} className="pointer-events-none" />
{getI18nText(opt.label)}
</button>
</div>
{opt.has_input && selected && (
<input
type="text"
@@ -82,7 +82,6 @@ export default function SystemStatusCard({
fetchStatus();
const interval = setInterval(fetchStatus, 30_000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchStatus, refreshKey]);
const pluginOk = pluginStatus
@@ -15,6 +15,7 @@ import {
At,
Quote,
Voice,
File as FileComponent,
Source,
} from '@/app/infra/entities/message';
import { toast } from 'sonner';
@@ -64,7 +65,12 @@ export default function DebugDialog({
const [isHovering, setIsHovering] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [selectedImages, setSelectedImages] = useState<
Array<{ file: File; preview: string; fileKey?: string }>
Array<{
file: File;
preview: string;
fileKey?: string;
kind: 'image' | 'voice' | 'file';
}>
>([]);
const [isUploading, setIsUploading] = useState(false);
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
@@ -292,23 +298,38 @@ export default function DebugDialog({
const files = e.target.files;
if (!files || files.length === 0) return;
const newImages: Array<{ file: File; preview: string }> = [];
const newImages: Array<{
file: File;
preview: string;
kind: 'image' | 'voice' | 'file';
}> = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.type.startsWith('image/')) {
const preview = URL.createObjectURL(file);
newImages.push({ file, preview });
newImages.push({
file,
preview: URL.createObjectURL(file),
kind: 'image',
});
} else if (file.type.startsWith('audio/')) {
newImages.push({ file, preview: '', kind: 'voice' });
} else {
newImages.push({ file, preview: '', kind: 'file' });
}
}
setSelectedImages((prev) => [...prev, ...newImages]);
// reset the input so selecting the same file again re-triggers onChange
e.target.value = '';
};
const handleRemoveImage = (index: number) => {
setSelectedImages((prev) => {
const newImages = [...prev];
URL.revokeObjectURL(newImages[index].preview);
if (newImages[index].preview) {
URL.revokeObjectURL(newImages[index].preview);
}
newImages.splice(index, 1);
return newImages;
});
@@ -372,19 +393,33 @@ export default function DebugDialog({
});
}
// Upload images and add to message chain
for (const image of selectedImages) {
// Upload attachments and add to message chain
for (const attachment of selectedImages) {
try {
const result = await httpClient.uploadWebSocketImage(
selectedPipelineId,
image.file,
);
messageChain.push({
type: 'Image',
path: result.file_key,
});
if (attachment.kind === 'image') {
const result = await httpClient.uploadWebSocketImage(
selectedPipelineId,
attachment.file,
);
messageChain.push({
type: 'Image',
path: result.file_key,
});
} else {
// Voice / File go through the generic document upload endpoint,
// which returns a storage key the backend resolves into the
// sandbox inbox just like images.
const result = await httpClient.uploadDocumentFile(attachment.file);
messageChain.push({
type: attachment.kind === 'voice' ? 'Voice' : 'File',
path: result.file_id,
...(attachment.kind === 'file'
? { name: attachment.file.name }
: {}),
});
}
} catch (error) {
console.error('Image upload failed:', error);
console.error('Attachment upload failed:', error);
toast.error(t('pipelines.debugDialog.imageUploadFailed'));
}
}
@@ -393,7 +428,9 @@ export default function DebugDialog({
setInputValue('');
setHasAt(false);
setQuotedMessage(null);
selectedImages.forEach((img) => URL.revokeObjectURL(img.preview));
selectedImages.forEach((img) => {
if (img.preview) URL.revokeObjectURL(img.preview);
});
setSelectedImages([]);
// Send message via WebSocket
@@ -460,13 +497,29 @@ export default function DebugDialog({
}
case 'File': {
const file = component as MessageChainComponent & { name?: string };
const file = component as FileComponent;
const downloadHref = file.base64
? file.base64.startsWith('data:')
? file.base64
: `data:application/octet-stream;base64,${file.base64}`
: file.url || '';
const fileName = file.name || 'Unknown';
return (
<div key={index} className="my-2 flex items-center gap-2 text-sm">
<Paperclip className="size-4" />
<span>
[{t('pipelines.debugDialog.file')}] {file.name || 'Unknown'}
</span>
{downloadHref ? (
<a
href={downloadHref}
download={fileName}
className="text-primary underline hover:opacity-80"
>
[{t('pipelines.debugDialog.file')}] {fileName}
</a>
) : (
<span>
[{t('pipelines.debugDialog.file')}] {fileName}
</span>
)}
</div>
);
}
@@ -844,17 +897,30 @@ export default function DebugDialog({
</div>
)}
{/* Image preview area */}
{/* Attachment preview area */}
{selectedImages.length > 0 && (
<div className="px-4 pb-2">
<div className="flex gap-2 flex-wrap">
{selectedImages.map((image, index) => (
<div key={index} className="relative group">
<img
src={image.preview}
alt={`preview-${index}`}
className="w-20 h-20 object-cover rounded-lg border"
/>
{image.kind === 'image' ? (
<img
src={image.preview}
alt={`preview-${index}`}
className="w-20 h-20 object-cover rounded-lg border"
/>
) : (
<div className="w-36 h-20 px-2 rounded-lg border bg-muted/40 flex items-center gap-2 overflow-hidden">
{image.kind === 'voice' ? (
<Music className="size-5 shrink-0 text-muted-foreground" />
) : (
<Paperclip className="size-5 shrink-0 text-muted-foreground" />
)}
<span className="text-xs text-muted-foreground truncate">
{image.file.name}
</span>
</div>
)}
<button
type="button"
onClick={() => handleRemoveImage(index)}
@@ -883,7 +949,7 @@ export default function DebugDialog({
<input
ref={fileInputRef}
type="file"
accept="image/*"
accept="image/*,audio/*,*/*"
multiple
onChange={handleImageSelect}
className="hidden"
@@ -325,7 +325,7 @@ export default function PipelineFormComponent({
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentValues =
(form.getValues(formName) as Record<string, unknown>) || {};
(form.getValues(formName) as Record<string, unknown>) || {};
form.setValue(formName, {
...currentValues,
[stageName]: values,
@@ -402,7 +402,7 @@ export default function PipelineFormComponent({
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
(form.watch(formName) as Record<string, unknown>)?.[
(form.watch(formName) as Record<string, unknown>)?.[
stage.name
] || {}
}
@@ -451,7 +451,7 @@ export default function PipelineFormComponent({
<CardContent className="space-y-6">
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={effectiveInitialValues}
initialValues={effectiveInitialValues}
onSubmit={(values) => {
handleRunnerConfigEmit(stage.name, values);
}}
@@ -475,7 +475,7 @@ export default function PipelineFormComponent({
// make the locked selector display a scope that is NOT the one actually in
// effect. Coerce the displayed/saved value to the forced template so the UI
// truthfully reflects runtime behavior.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stageInitialValues: Record<string, any> =
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
const effectiveInitialValues =
@@ -9,7 +9,7 @@ export interface IPluginCardVO {
enabled: boolean;
priority: number;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
install_info: Record<string, any>;
status: string;
components: PluginComponent[];
debug: boolean;
@@ -27,7 +27,7 @@ export class PluginCardVO implements IPluginCardVO {
priority: number;
debug: boolean;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
install_info: Record<string, any>;
status: string;
components: PluginComponent[];
hasUpdate?: boolean;
+1 -1
View File
@@ -21,7 +21,7 @@ export interface ComponentManifest {
version?: string;
author?: string;
};
spec: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
spec: Record<string, any>;
}
export interface CustomApiError {
+1 -1
View File
@@ -8,7 +8,7 @@ export const SYSTEM_FIELD_PREFIX = '__system.';
export interface IShowIfCondition {
field: string;
operator: 'eq' | 'neq' | 'in';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
}
@@ -64,6 +64,8 @@ export interface File extends MessageComponent {
name?: string;
size?: number;
url?: string;
path?: string;
base64?: string;
}
// Unknown component
+1 -1
View File
@@ -10,7 +10,7 @@ export interface Plugin {
debug: boolean;
enabled: boolean;
install_source: string;
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
install_info: Record<string, any>;
components: PluginComponent[];
}
+12
View File
@@ -122,6 +122,8 @@ const enUS = {
changePasswordFailed:
'Failed to change password, please check your current password',
apiIntegration: 'API Integration',
apiIntegrationDescription:
'Manage API keys and webhooks for external access',
apiKeys: 'API Keys',
manageApiIntegration: 'Manage API Integration',
manageApiKeys: 'Manage API Keys',
@@ -1149,6 +1151,7 @@ const enUS = {
},
account: {
settings: 'Account Settings',
settingsDescription: 'Manage your password and linked accounts',
setPassword: 'Set Password',
passwordSetSuccess: 'Password set successfully',
passwordStatus: 'Local Password',
@@ -1386,6 +1389,15 @@ const enUS = {
boxSessionCreated: 'Created',
boxSessionLastUsed: 'Last used',
},
settingsDialog: {
title: 'Settings',
nav: {
models: 'Models',
api: 'API',
storage: 'Storage',
account: 'Account',
},
},
storageAnalysis: {
title: 'Storage Analysis',
description: 'Inspect storage usage and cleanup candidates',
+13 -1
View File
@@ -126,6 +126,8 @@ const esES = {
changePasswordFailed:
'Error al cambiar la contraseña, por favor verifica tu contraseña actual',
apiIntegration: 'Integración API',
apiIntegrationDescription:
'Gestiona las claves API y los webhooks para el acceso externo',
apiKeys: 'Claves API',
manageApiIntegration: 'Gestionar integración API',
manageApiKeys: 'Gestionar claves API',
@@ -844,7 +846,7 @@ const esES = {
'Una vez eliminada, la configuración de este servidor MCP no se podrá recuperar.',
},
pipelines: {
title: 'Pipelines',
title: 'Flujos',
description:
'Los Pipelines definen el flujo de procesamiento de eventos de mensajes, se usan para vincular a los Bots',
createPipeline: 'Crear Pipeline',
@@ -1178,6 +1180,7 @@ const esES = {
},
account: {
settings: 'Configuración de la cuenta',
settingsDescription: 'Gestiona tu contraseña y las cuentas vinculadas',
setPassword: 'Establecer contraseña',
passwordSetSuccess: 'Contraseña establecida correctamente',
passwordStatus: 'Contraseña local',
@@ -1419,6 +1422,15 @@ const esES = {
boxSessionCreated: 'Creado',
boxSessionLastUsed: 'Último uso',
},
settingsDialog: {
title: 'Configuración',
nav: {
models: 'Modelos',
api: 'API',
storage: 'Almacenamiento',
account: 'Cuenta',
},
},
storageAnalysis: {
title: 'Análisis de almacenamiento',
description:
+12
View File
@@ -124,6 +124,8 @@ const jaJP = {
changePasswordFailed:
'パスワードの変更に失敗しました。現在のパスワードを確認してください',
apiIntegration: 'API統合',
apiIntegrationDescription:
'外部アクセス用の API キーと Webhook を管理します',
apiKeys: 'API キー',
manageApiIntegration: 'API統合の管理',
manageApiKeys: 'API キーの管理',
@@ -1153,6 +1155,7 @@ const jaJP = {
},
account: {
settings: 'アカウント設定',
settingsDescription: 'パスワードと連携アカウントを管理します',
setPassword: 'パスワードを設定',
passwordSetSuccess: 'パスワードの設定に成功しました',
passwordStatus: 'ローカルパスワード',
@@ -1392,6 +1395,15 @@ const jaJP = {
boxSessionCreated: '作成日時',
boxSessionLastUsed: '最終使用',
},
settingsDialog: {
title: '設定',
nav: {
models: 'モデル',
api: 'API',
storage: 'ストレージ',
account: 'アカウント',
},
},
storageAnalysis: {
title: 'ストレージ分析',
description: 'ストレージ使用量とクリーンアップ候補を確認します',
+12
View File
@@ -122,6 +122,8 @@ const ruRU = {
changePasswordFailed:
'Не удалось изменить пароль, проверьте текущий пароль',
apiIntegration: 'API-интеграция',
apiIntegrationDescription:
'Управление API-ключами и вебхуками для внешнего доступа',
apiKeys: 'API-ключи',
manageApiIntegration: 'Управление API-интеграцией',
manageApiKeys: 'Управление API-ключами',
@@ -1156,6 +1158,7 @@ const ruRU = {
},
account: {
settings: 'Настройки аккаунта',
settingsDescription: 'Управление паролем и связанными аккаунтами',
setPassword: 'Установить пароль',
passwordSetSuccess: 'Пароль успешно установлен',
passwordStatus: 'Локальный пароль',
@@ -1395,6 +1398,15 @@ const ruRU = {
boxSessionCreated: 'Создано',
boxSessionLastUsed: 'Последнее использование',
},
settingsDialog: {
title: 'Настройки',
nav: {
models: 'Модели',
api: 'API',
storage: 'Хранилище',
account: 'Аккаунт',
},
},
storageAnalysis: {
title: 'Анализ хранилища',
description: 'Проверьте использование хранилища и кандидатов на очистку',
+14 -2
View File
@@ -120,6 +120,8 @@ const thTH = {
changePasswordSuccess: 'เปลี่ยนรหัสผ่านสำเร็จ',
changePasswordFailed: 'เปลี่ยนรหัสผ่านล้มเหลว กรุณาตรวจสอบรหัสผ่านปัจจุบัน',
apiIntegration: 'การเชื่อมต่อ API',
apiIntegrationDescription:
'จัดการ API key และ webhook สำหรับการเข้าถึงจากภายนอก',
apiKeys: 'คีย์ API',
manageApiIntegration: 'จัดการการเชื่อมต่อ API',
manageApiKeys: 'จัดการคีย์ API',
@@ -300,7 +302,7 @@ const thTH = {
},
},
bots: {
title: 'Bot',
title: 'บอท',
description:
'สร้างและจัดการ Bot ซึ่งเป็นจุดเชื่อมต่อของ LangBot กับแพลตฟอร์มต่างๆ',
createBot: 'สร้าง Bot',
@@ -819,7 +821,7 @@ const thTH = {
'เมื่อลบแล้ว การกำหนดค่าเซิร์ฟเวอร์ MCP นี้จะไม่สามารถกู้คืนได้',
},
pipelines: {
title: 'Pipeline',
title: 'ไปป์ไลน์',
description:
'Pipeline กำหนดกระบวนการประมวลผลเหตุการณ์ข้อความ ใช้เพื่อผูกกับ Bot',
createPipeline: 'สร้าง Pipeline',
@@ -1130,6 +1132,7 @@ const thTH = {
},
account: {
settings: 'การตั้งค่าบัญชี',
settingsDescription: 'จัดการรหัสผ่านและบัญชีที่เชื่อมโยงของคุณ',
setPassword: 'ตั้งรหัสผ่าน',
passwordSetSuccess: 'ตั้งรหัสผ่านสำเร็จ',
passwordStatus: 'รหัสผ่านท้องถิ่น',
@@ -1364,6 +1367,15 @@ const thTH = {
boxSessionCreated: 'สร้างเมื่อ',
boxSessionLastUsed: 'ใช้ล่าสุด',
},
settingsDialog: {
title: 'การตั้งค่า',
nav: {
models: 'โมเดล',
api: 'API',
storage: 'พื้นที่จัดเก็บ',
account: 'บัญชี',
},
},
storageAnalysis: {
title: 'วิเคราะห์พื้นที่จัดเก็บ',
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
+13 -1
View File
@@ -123,6 +123,8 @@ const viVN = {
changePasswordFailed:
'Đổi mật khẩu thất bại, vui lòng kiểm tra mật khẩu hiện tại',
apiIntegration: 'Tích hợp API',
apiIntegrationDescription:
'Quản lý API key và webhook cho truy cập từ bên ngoài',
apiKeys: 'Khóa API',
manageApiIntegration: 'Quản lý tích hợp API',
manageApiKeys: 'Quản lý khóa API',
@@ -833,7 +835,7 @@ const viVN = {
deleteMCPHint: 'Sau khi xóa, cấu hình máy chủ MCP này không thể khôi phục.',
},
pipelines: {
title: 'Pipeline',
title: 'Quy trình',
description:
'Pipeline xác định luồng xử lý sự kiện tin nhắn, dùng để liên kết với Bot',
createPipeline: 'Tạo Pipeline',
@@ -1150,6 +1152,7 @@ const viVN = {
},
account: {
settings: 'Cài đặt tài khoản',
settingsDescription: 'Quản lý mật khẩu và các tài khoản liên kết của bạn',
setPassword: 'Đặt mật khẩu',
passwordSetSuccess: 'Đặt mật khẩu thành công',
passwordStatus: 'Mật khẩu cục bộ',
@@ -1388,6 +1391,15 @@ const viVN = {
boxSessionCreated: 'Đã tạo',
boxSessionLastUsed: 'Lần cuối sử dụng',
},
settingsDialog: {
title: 'Cài đặt',
nav: {
models: 'Mô hình',
api: 'API',
storage: 'Lưu trữ',
account: 'Tài khoản',
},
},
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',
+11
View File
@@ -116,6 +116,7 @@ const zhHans = {
changePasswordSuccess: '密码修改成功',
changePasswordFailed: '密码修改失败,请检查当前密码是否正确',
apiIntegration: 'API 集成',
apiIntegrationDescription: '管理用于外部访问的 API 密钥和 Webhook',
apiKeys: 'API 密钥',
manageApiIntegration: '管理 API 集成',
manageApiKeys: '管理 API 密钥',
@@ -1096,6 +1097,7 @@ const zhHans = {
},
account: {
settings: '账户设置',
settingsDescription: '管理你的密码和关联账户',
setPassword: '设置密码',
passwordSetSuccess: '密码设置成功',
passwordStatus: '本地密码',
@@ -1328,6 +1330,15 @@ const zhHans = {
boxSessionCreated: '创建时间',
boxSessionLastUsed: '最后使用',
},
settingsDialog: {
title: '设置',
nav: {
models: '模型',
api: 'API',
storage: '存储',
account: '账户',
},
},
storageAnalysis: {
title: '存储分析',
description: '查看存储占用和可清理文件',
+11
View File
@@ -116,6 +116,7 @@ const zhHant = {
changePasswordSuccess: '密碼修改成功',
changePasswordFailed: '密碼修改失敗,請檢查當前密碼是否正確',
apiIntegration: 'API 整合',
apiIntegrationDescription: '管理用於外部存取的 API 金鑰和 Webhook',
apiKeys: 'API 金鑰',
manageApiIntegration: '管理 API 整合',
manageApiKeys: '管理 API 金鑰',
@@ -1095,6 +1096,7 @@ const zhHant = {
},
account: {
settings: '帳戶設定',
settingsDescription: '管理你的密碼和關聯帳戶',
setPassword: '設定密碼',
passwordSetSuccess: '密碼設定成功',
passwordStatus: '本地密碼',
@@ -1327,6 +1329,15 @@ const zhHant = {
boxSessionCreated: '建立時間',
boxSessionLastUsed: '最後使用',
},
settingsDialog: {
title: '設定',
nav: {
models: '模型',
api: 'API',
storage: '儲存',
account: '帳戶',
},
},
storageAnalysis: {
title: '儲存分析',
description: '查看儲存占用和可清理檔案',
+455
View File
@@ -0,0 +1,455 @@
import { expect, Page, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
async function save(page: Page) {
const button = page.getByRole('button', { name: /^Save$/ });
await expect(button).toBeEnabled();
await button.click();
}
async function submit(page: Page) {
await page.getByRole('button', { name: /^Submit$/ }).click();
}
async function confirmDelete(page: Page) {
await page
.getByRole('dialog')
.getByRole('button', { name: /^Confirm Delete$/ })
.click();
}
test.describe('frontend CRUD smoke flows', () => {
test('creates, edits, and deletes a bot', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots?id=new');
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('Support Bot');
await page
.locator('input[name="description"]')
.fill('Answers customer support questions.');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue('Support Bot');
await page
.locator('input[name="description"]')
.fill('Answers customer support questions with context.');
await save(page);
await expect(page.locator('input[name="description"]')).toHaveValue(
'Answers customer support questions with context.',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/bots$/);
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
});
test('creates, edits, and deletes a pipeline', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines?id=new');
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page.locator('input[name="basic.name"]').fill('Escalation Pipeline');
await page
.locator('input[name="basic.description"]')
.fill('Routes urgent customer issues.');
await submit(page);
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
await page.reload();
await expect(page.locator('input[name="basic.name"]')).toHaveValue(
'Escalation Pipeline',
);
await page
.locator('input[name="basic.description"]')
.fill('Routes urgent customer issues to operators.');
await save(page);
await expect(page.locator('input[name="basic.description"]')).toHaveValue(
'Routes urgent customer issues to operators.',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/pipelines$/);
await expect(
page.getByText('Select a pipeline from the sidebar'),
).toBeVisible();
});
test('creates, edits, and deletes a knowledge base', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/knowledge?id=new');
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('Support Knowledge');
await page
.locator('input[name="description"]')
.fill('Source material for support answers.');
await submit(page);
await expect(page).toHaveURL(/\/home\/knowledge\?id=knowledge-1$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue(
'Support Knowledge',
);
await page.waitForTimeout(600);
await page
.locator('input[name="description"]')
.fill('Updated source material for support answers.');
await save(page);
await expect(page.locator('input[name="description"]')).toHaveValue(
'Updated source material for support answers.',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/knowledge$/);
await expect(
page.getByText('Select a knowledge base from the sidebar'),
).toBeVisible();
});
test('creates, edits, and deletes an MCP server', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/mcp?id=new');
await expect(page.locator('input[name="name"]')).toBeVisible();
await page.locator('input[name="name"]').fill('playwright-mcp');
await page
.locator('input[name="url"]')
.fill('https://mcp.example.test/sse');
await submit(page);
await expect(page).toHaveURL(/\/home\/mcp\?id=playwright-mcp$/);
await page.reload();
await expect(page.locator('input[name="name"]')).toHaveValue(
'playwright-mcp',
);
await page
.locator('input[name="url"]')
.fill('https://mcp.example.test/updated-sse');
await save(page);
await expect(page.locator('input[name="url"]')).toHaveValue(
'https://mcp.example.test/updated-sse',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/mcp$/);
await expect(
page.getByText('Select an MCP server from the sidebar'),
).toBeVisible();
});
test('updates and deletes a manually-created skill', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/skills?action=create');
await page.locator('#display_name').fill('Release Notes');
await page.locator('#name').fill('release_notes');
await page.locator('#description').fill('Drafts release notes.');
await page
.locator('#instructions')
.fill('Summarize merged changes for the next release.');
await save(page);
await expect(page).toHaveURL(/\/home\/skills\?id=release_notes$/);
await page.reload();
await expect(page.locator('#description')).toHaveValue(
'Drafts release notes.',
);
await page
.locator('#description')
.fill('Drafts concise release notes for maintainers.');
await expect(page.locator('#description')).toHaveValue(
'Drafts concise release notes for maintainers.',
);
await save(page);
await page.reload();
await expect(page.locator('#description')).toHaveValue(
'Drafts concise release notes for maintainers.',
);
await expect(page.locator('#instructions')).toHaveValue(
'Summarize merged changes for the next release.',
);
await page.getByRole('button', { name: /^Delete$/ }).click();
await confirmDelete(page);
await expect(page).toHaveURL(/\/home\/add-extension$/);
});
});
test.describe('bot advanced flows', () => {
test('toggles bot enable/disable state', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a bot first
await page.goto('/home/bots?id=new');
await page.locator('input[name="name"]').fill('Toggle Test Bot');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Wait for the enable switch to load (it's fetched via getBot)
await expect(page.locator('#bot-enable-switch')).toBeVisible({
timeout: 5000,
});
// Verify initial state is enabled
await expect(page.locator('#bot-enable-switch')).toBeChecked();
// Toggle to disabled
await page.locator('#bot-enable-switch').click();
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
// Reload and verify state persisted
await page.reload();
await expect(page.locator('#bot-enable-switch')).not.toBeChecked();
});
test('switches between bot detail tabs', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a bot
await page.goto('/home/bots?id=new');
await page.locator('input[name="name"]').fill('Tab Test Bot');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
// Verify we're on the Configuration tab
await expect(
page.getByRole('tab', { name: /Configuration/ }),
).toHaveAttribute('data-state', 'active');
await expect(page.locator('input[name="name"]')).toBeVisible();
// Switch to Logs tab
await page.getByRole('tab', { name: /Logs/ }).click();
await expect(page.getByRole('tab', { name: /Logs/ })).toHaveAttribute(
'data-state',
'active',
);
// Switch to Sessions tab
await page.getByRole('tab', { name: /Sessions/ }).click();
await expect(page.getByRole('tab', { name: /Sessions/ })).toHaveAttribute(
'data-state',
'active',
);
// Switch back to Configuration
await page.getByRole('tab', { name: /Configuration/ }).click();
await expect(page.locator('input[name="name"]')).toBeVisible();
});
test('save button is disabled when form is clean', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a bot
await page.goto('/home/bots?id=new');
await page.locator('input[name="name"]').fill('Clean Form Bot');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
// After creation, save button should be disabled (form is clean)
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeDisabled();
// Edit the form
await page.locator('input[name="description"]').fill('New description');
await expect(saveButton).toBeEnabled();
// Save
await saveButton.click();
await expect(saveButton).toBeDisabled();
});
test('shows validation error when bot name is empty', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots?id=new');
// Select adapter but leave name empty
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
// Should show validation error for name (zod validation)
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
await expect(page).toHaveURL(/\/home\/bots\?id=new$/);
});
});
test.describe('pipeline advanced flows', () => {
test('switches to monitoring tab from pipeline detail', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline
await page.goto('/home/pipelines?id=new');
await page.locator('input[name="basic.name"]').fill('Tab Test Pipeline');
await submit(page);
// Verify we're on the Configuration tab
await expect(
page.getByRole('tab', { name: /Configuration/ }),
).toHaveAttribute('data-state', 'active');
// Switch to Monitoring tab (labeled "Dashboard" in the pipeline context)
// Skip Debug tab as it requires WebSocket connection
await page.getByRole('tab', { name: /Dashboard/ }).click();
await expect(page.getByRole('tab', { name: /Dashboard/ })).toHaveAttribute(
'data-state',
'active',
);
// Switch back to Configuration
await page.getByRole('tab', { name: /Configuration/ }).click();
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
});
test('save button reflects form dirty state', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline
await page.goto('/home/pipelines?id=new');
await page.locator('input[name="basic.name"]').fill('Dirty Form Pipeline');
await submit(page);
// Wait for the page to fully load and form to reset
await page.waitForTimeout(500);
// Edit the form - use the name field which definitely triggers dirty state
await page
.locator('input[name="basic.name"]')
.fill('Dirty Form Pipeline Updated');
const saveButton = page.getByRole('button', { name: /^Save$/ });
await expect(saveButton).toBeEnabled();
// Save
await saveButton.click();
// Wait for save to complete
await page.waitForTimeout(500);
});
test('shows validation error when pipeline name is empty', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines?id=new');
// Submit without filling name
await submit(page);
// Should show validation error for name (zod validation)
await expect(page.getByText(/cannot be empty/i)).toBeVisible();
await expect(page).toHaveURL(/\/home\/pipelines\?id=new$/);
});
});
test.describe('cross-resource flows', () => {
test('creates a pipeline then binds it to a bot', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
// Create a pipeline first
await page.goto('/home/pipelines?id=new');
await page.locator('input[name="basic.name"]').fill('Production Pipeline');
await submit(page);
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
// Create a bot
await page.goto('/home/bots?id=new');
await page.locator('input[name="name"]').fill('Bound Bot');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await submit(page);
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
// Wait for form to fully load
await expect(page.locator('input[name="name"]')).toHaveValue('Bound Bot');
// Find the pipeline select by its label "Bind Pipeline"
const pipelineCard = page.getByText('Bind Pipeline').locator('..');
await expect(pipelineCard).toBeVisible({ timeout: 5000 });
// Click on the select trigger within the pipeline binding card
// The select trigger shows "Select Pipeline" placeholder initially
const pipelineSelectTrigger = page.getByText('Select Pipeline').first();
await pipelineSelectTrigger.click();
// Select the pipeline option
await page.getByRole('option', { name: 'Production Pipeline' }).click();
// Save the bot
await save(page);
// Reload and verify binding persisted
await page.reload();
// The pipeline name should appear in the select trigger (not in sidebar or options)
await expect(
page
.locator('[data-slot="select-trigger"]')
.filter({ hasText: 'Production Pipeline' }),
).toBeVisible();
});
});
test.describe('empty states', () => {
test('shows empty state when no bots exist', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots');
await expect(page.getByText('Select a bot from the sidebar')).toBeVisible();
});
test('shows empty state when no pipelines exist', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines');
await expect(
page.getByText('Select a pipeline from the sidebar'),
).toBeVisible();
});
test('shows empty state when no knowledge bases exist', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/knowledge');
await expect(
page.getByText('Select a knowledge base from the sidebar'),
).toBeVisible();
});
test('shows empty state when no MCP servers exist', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/mcp');
await expect(
page.getByText('Select an MCP server from the sidebar'),
).toBeVisible();
});
});
+835
View File
@@ -0,0 +1,835 @@
import { Page, Route } from '@playwright/test';
type JsonRecord = Record<string, unknown>;
interface SkillMock {
name: string;
display_name: string;
description: string;
instructions: string;
package_root: string;
updated_at: string;
}
interface PipelineMock {
uuid: string;
name: string;
description: string;
config: JsonRecord;
emoji: string;
is_default: boolean;
updated_at: string;
}
interface KnowledgeBaseMock {
uuid: string;
name: string;
description: string;
emoji: string;
knowledge_engine_plugin_id: string;
creation_settings: JsonRecord;
retrieval_settings: JsonRecord;
knowledge_engine: {
plugin_id: string;
name: {
en_US: string;
zh_Hans: string;
};
capabilities: string[];
};
updated_at: string;
}
interface MCPServerMock {
name: string;
mode: 'sse' | 'stdio' | 'http';
enable: boolean;
extra_args: JsonRecord;
runtime_info: {
status: 'connected';
tool_count: number;
tools: unknown[];
};
readme: string;
updated_at: string;
}
interface BotMock {
uuid: string;
name: string;
description: string;
enable: boolean;
adapter: string;
adapter_config: JsonRecord;
use_pipeline_uuid?: string;
pipeline_routing_rules: unknown[];
adapter_runtime_values: JsonRecord;
updated_at: string;
}
interface LangBotApiMockState {
bots: BotMock[];
counters: Record<string, number>;
knowledgeBases: KnowledgeBaseMock[];
mcpServers: MCPServerMock[];
pipelines: PipelineMock[];
skills: SkillMock[];
}
function ok(data: unknown) {
return {
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
};
}
async function fulfillJson(route: Route, data: unknown) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(ok(data)),
});
}
function routePath(route: Route) {
return new URL(route.request().url()).pathname;
}
function parseJsonBody(route: Route): JsonRecord {
return JSON.parse(route.request().postData() || '{}') as JsonRecord;
}
function now() {
return new Date().toISOString();
}
function nextId(state: LangBotApiMockState, prefix: string) {
state.counters[prefix] = (state.counters[prefix] || 0) + 1;
return `${prefix}-${state.counters[prefix]}`;
}
function emptyMonitoringData() {
return {
overview: {
total_messages: 0,
llm_calls: 0,
embedding_calls: 0,
model_calls: 0,
success_rate: 0,
active_sessions: 0,
},
messages: [],
llmCalls: [],
embeddingCalls: [],
sessions: [],
errors: [],
totalCount: {
messages: 0,
llmCalls: 0,
embeddingCalls: 0,
sessions: 0,
errors: 0,
},
};
}
function emptyTokenStatistics() {
return {
summary: {
total_calls: 0,
success_calls: 0,
error_calls: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_tokens: 0,
total_cost: 0,
avg_tokens_per_call: 0,
avg_duration_ms: 0,
avg_tokens_per_second: 0,
zero_token_success_calls: 0,
},
by_model: [],
timeseries: [],
bucket: 'day',
};
}
function makeSkill(data: JsonRecord): SkillMock {
return {
name: String(data.name || ''),
display_name: String(data.display_name || ''),
description: String(data.description || ''),
instructions: String(data.instructions || ''),
package_root: String(data.package_root || ''),
updated_at: new Date().toISOString(),
};
}
function makePipeline(
state: LangBotApiMockState,
data: JsonRecord,
uuid = nextId(state, 'pipeline'),
): PipelineMock {
return {
uuid,
name: String(data.name || ''),
description: String(data.description || ''),
config: (data.config as JsonRecord | undefined) || {
ai: {},
trigger: {},
safety: {},
output: {},
},
emoji: String(data.emoji || '⚙️'),
is_default: false,
updated_at: now(),
};
}
function knowledgeEngine() {
return {
plugin_id: 'builtin/minimal-knowledge',
name: {
en_US: 'Minimal Knowledge Engine',
zh_Hans: '最小知识库引擎',
},
description: {
en_US: 'Minimal mocked engine for frontend smoke tests.',
zh_Hans: '用于前端冒烟测试的最小模拟引擎。',
},
capabilities: ['text_retrieval'],
creation_schema: [],
retrieval_schema: [],
};
}
function makeKnowledgeBase(
state: LangBotApiMockState,
data: JsonRecord,
uuid = nextId(state, 'knowledge'),
): KnowledgeBaseMock {
const engine = knowledgeEngine();
return {
uuid,
name: String(data.name || ''),
description: String(data.description || ''),
emoji: String(data.emoji || '📚'),
knowledge_engine_plugin_id: String(
data.knowledge_engine_plugin_id || engine.plugin_id,
),
creation_settings: (data.creation_settings as JsonRecord | undefined) || {},
retrieval_settings:
(data.retrieval_settings as JsonRecord | undefined) || {},
knowledge_engine: {
plugin_id: engine.plugin_id,
name: engine.name,
capabilities: engine.capabilities,
},
updated_at: now(),
};
}
function makeMCPServer(data: JsonRecord): MCPServerMock {
return {
name: String(data.name || ''),
mode: (data.mode as MCPServerMock['mode']) || 'sse',
enable: data.enable !== false,
extra_args: (data.extra_args as JsonRecord | undefined) || {},
runtime_info: {
status: 'connected',
tool_count: 0,
tools: [],
},
readme: '',
updated_at: now(),
};
}
function makeBot(
state: LangBotApiMockState,
data: JsonRecord,
uuid = nextId(state, 'bot'),
): BotMock {
return {
uuid,
name: String(data.name || ''),
description: String(data.description || ''),
enable: data.enable !== false,
adapter: String(data.adapter || 'playwright-adapter'),
adapter_config: (data.adapter_config as JsonRecord | undefined) || {},
use_pipeline_uuid: data.use_pipeline_uuid
? String(data.use_pipeline_uuid)
: undefined,
pipeline_routing_rules:
(data.pipeline_routing_rules as unknown[] | undefined) || [],
adapter_runtime_values: {
webhook_full_url: `https://playwright.test/bots/${uuid}/webhook`,
extra_webhook_full_url: '',
},
updated_at: now(),
};
}
function mockAdapters() {
return [
{
name: 'playwright-adapter',
label: {
en_US: 'Playwright Adapter',
zh_Hans: 'Playwright 适配器',
},
description: {
en_US: 'Minimal adapter for frontend E2E tests.',
zh_Hans: '用于前端 E2E 测试的最小适配器。',
},
spec: {
categories: ['testing'],
config: [],
},
},
];
}
async function handleBackendApi(route: Route, state: LangBotApiMockState) {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const method = request.method();
if (path === '/api/v1/system/info') {
return fulfillJson(route, {
debug: false,
version: 'frontend-smoke',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
allow_modify_login_info: true,
disable_models_service: false,
limitation: {
max_bots: -1,
max_pipelines: -1,
max_extensions: -1,
},
outbound_ips: [],
wizard_status: 'completed',
wizard_progress: null,
});
}
if (path === '/api/v1/user/account-info') {
return fulfillJson(route, {
initialized: true,
account_type: 'local',
has_password: true,
});
}
if (path === '/api/v1/user/check-token') {
return fulfillJson(route, { token: '' });
}
if (path === '/api/v1/user/auth') {
return fulfillJson(route, { token: 'playwright-token' });
}
if (path === '/api/v1/user/info') {
return fulfillJson(route, {
user: 'admin@example.com',
account_type: 'local',
has_password: true,
});
}
if (path === '/api/v1/user/space-credits') {
return fulfillJson(route, { credits: null });
}
if (path === '/api/v1/platform/adapters') {
return fulfillJson(route, { adapters: mockAdapters() });
}
if (path === '/api/v1/platform/bots') {
if (method === 'POST') {
const bot = makeBot(state, parseJsonBody(route));
state.bots = [
...state.bots.filter((item) => item.uuid !== bot.uuid),
bot,
];
return fulfillJson(route, { uuid: bot.uuid });
}
return fulfillJson(route, { bots: state.bots });
}
const botLogsMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)\/logs$/);
if (botLogsMatch) {
return fulfillJson(route, { logs: [], total: 0 });
}
const botMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)$/);
if (botMatch) {
const botId = decodeURIComponent(botMatch[1]);
if (method === 'PUT') {
const bot = makeBot(state, parseJsonBody(route), botId);
state.bots = [...state.bots.filter((item) => item.uuid !== botId), bot];
return fulfillJson(route, {});
}
if (method === 'DELETE') {
state.bots = state.bots.filter((item) => item.uuid !== botId);
return fulfillJson(route, {});
}
const bot = state.bots.find((item) => item.uuid === botId);
return fulfillJson(route, {
bot: bot || makeBot(state, { name: botId }, botId),
});
}
if (path === '/api/v1/pipelines/_/metadata') {
return fulfillJson(route, { configs: [] });
}
if (path === '/api/v1/pipelines') {
if (method === 'POST') {
const pipeline = makePipeline(state, parseJsonBody(route));
state.pipelines = [
...state.pipelines.filter((item) => item.uuid !== pipeline.uuid),
pipeline,
];
return fulfillJson(route, { uuid: pipeline.uuid });
}
return fulfillJson(route, { pipelines: state.pipelines });
}
const pipelineMatch = path.match(/^\/api\/v1\/pipelines\/([^/]+)$/);
if (pipelineMatch) {
const pipelineId = decodeURIComponent(pipelineMatch[1]);
if (method === 'PUT') {
const pipeline = makePipeline(state, parseJsonBody(route), pipelineId);
state.pipelines = [
...state.pipelines.filter((item) => item.uuid !== pipelineId),
pipeline,
];
return fulfillJson(route, {});
}
if (method === 'DELETE') {
state.pipelines = state.pipelines.filter(
(item) => item.uuid !== pipelineId,
);
return fulfillJson(route, {});
}
const pipeline = state.pipelines.find((item) => item.uuid === pipelineId);
return fulfillJson(route, {
pipeline:
pipeline || makePipeline(state, { name: pipelineId }, pipelineId),
});
}
const pipelineExtensionsMatch = path.match(
/^\/api\/v1\/pipelines\/([^/]+)\/extensions$/,
);
if (pipelineExtensionsMatch) {
return fulfillJson(route, {
enable_all_plugins: true,
enable_all_mcp_servers: true,
enable_all_skills: true,
bound_plugins: [],
available_plugins: [],
bound_mcp_servers: [],
available_mcp_servers: state.mcpServers,
bound_skills: [],
available_skills: state.skills,
});
}
if (path === '/api/v1/knowledge/bases') {
if (method === 'POST') {
const base = makeKnowledgeBase(state, parseJsonBody(route));
state.knowledgeBases = [
...state.knowledgeBases.filter((item) => item.uuid !== base.uuid),
base,
];
return fulfillJson(route, { uuid: base.uuid });
}
return fulfillJson(route, { bases: state.knowledgeBases });
}
const knowledgeBaseFilesMatch = path.match(
/^\/api\/v1\/knowledge\/bases\/([^/]+)\/files$/,
);
if (knowledgeBaseFilesMatch) {
return fulfillJson(route, { files: [] });
}
const knowledgeBaseMatch = path.match(
/^\/api\/v1\/knowledge\/bases\/([^/]+)$/,
);
if (knowledgeBaseMatch) {
const baseId = decodeURIComponent(knowledgeBaseMatch[1]);
if (method === 'PUT') {
const base = makeKnowledgeBase(state, parseJsonBody(route), baseId);
state.knowledgeBases = [
...state.knowledgeBases.filter((item) => item.uuid !== baseId),
base,
];
return fulfillJson(route, { uuid: base.uuid });
}
if (method === 'DELETE') {
state.knowledgeBases = state.knowledgeBases.filter(
(item) => item.uuid !== baseId,
);
return fulfillJson(route, {});
}
const base = state.knowledgeBases.find((item) => item.uuid === baseId);
return fulfillJson(route, {
base: base || makeKnowledgeBase(state, { name: baseId }, baseId),
});
}
if (path === '/api/v1/knowledge/engines') {
return fulfillJson(route, { engines: [knowledgeEngine()] });
}
if (path === '/api/v1/knowledge/migration/status') {
return fulfillJson(route, {
needed: false,
internal_kb_count: 0,
external_kb_count: 0,
});
}
if (path === '/api/v1/plugins') {
return fulfillJson(route, { plugins: [] });
}
if (path === '/api/v1/extensions') {
return fulfillJson(route, { extensions: [] });
}
if (path === '/api/v1/mcp/servers') {
if (method === 'POST') {
const server = makeMCPServer(parseJsonBody(route));
state.mcpServers = [
...state.mcpServers.filter((item) => item.name !== server.name),
server,
];
return fulfillJson(route, { task_id: nextId(state, 'task') });
}
return fulfillJson(route, { servers: state.mcpServers });
}
const mcpTestMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)\/test$/);
if (mcpTestMatch) {
return fulfillJson(route, {
runtime_info: {
status: 'connected',
tool_count: 0,
tools: [],
},
});
}
const mcpServerMatch = path.match(/^\/api\/v1\/mcp\/servers\/([^/]+)$/);
if (mcpServerMatch) {
const serverName = decodeURIComponent(mcpServerMatch[1]);
if (method === 'PUT') {
const existing = state.mcpServers.find(
(item) => item.name === serverName,
);
const server = makeMCPServer({
...(existing || {}),
...parseJsonBody(route),
name: serverName,
});
state.mcpServers = [
...state.mcpServers.filter((item) => item.name !== serverName),
server,
];
return fulfillJson(route, { task_id: nextId(state, 'task') });
}
if (method === 'DELETE') {
state.mcpServers = state.mcpServers.filter(
(item) => item.name !== serverName,
);
return fulfillJson(route, { task_id: nextId(state, 'task') });
}
const server = state.mcpServers.find((item) => item.name === serverName);
return fulfillJson(route, {
server: server || makeMCPServer({ name: serverName }),
});
}
if (path === '/api/v1/skills') {
if (method === 'POST') {
const skill = makeSkill(
JSON.parse(request.postData() || '{}') as JsonRecord,
);
state.skills = [
...state.skills.filter((item) => item.name !== skill.name),
skill,
];
return fulfillJson(route, { skill });
}
return fulfillJson(route, { skills: state.skills });
}
const skillFileMatch = path.match(
/^\/api\/v1\/skills\/([^/]+)\/files\/(.+)$/,
);
if (skillFileMatch) {
const skillName = decodeURIComponent(skillFileMatch[1]);
const filePath = decodeURIComponent(skillFileMatch[2]);
const skill = state.skills.find((item) => item.name === skillName);
return fulfillJson(route, {
skill: { name: skillName },
path: filePath,
content: skill?.instructions || '',
});
}
const skillFilesMatch = path.match(/^\/api\/v1\/skills\/([^/]+)\/files$/);
if (skillFilesMatch) {
const skillName = decodeURIComponent(skillFilesMatch[1]);
return fulfillJson(route, {
skill: { name: skillName },
base_path: '.',
entries: [
{
path: 'SKILL.md',
name: 'SKILL.md',
is_dir: false,
size: null,
},
],
truncated: false,
});
}
const skillMatch = path.match(/^\/api\/v1\/skills\/([^/]+)$/);
if (skillMatch) {
const skillName = decodeURIComponent(skillMatch[1]);
if (method === 'PUT') {
const skill = makeSkill({
...parseJsonBody(route),
name: skillName,
});
state.skills = [
...state.skills.filter((item) => item.name !== skillName),
skill,
];
return fulfillJson(route, { skill });
}
if (method === 'DELETE') {
state.skills = state.skills.filter((item) => item.name !== skillName);
return fulfillJson(route, {});
}
const skill = state.skills.find((item) => item.name === skillName) || {
name: skillName,
display_name: '',
description: '',
instructions: '',
package_root: '',
updated_at: new Date().toISOString(),
};
return fulfillJson(route, { skill });
}
if (path === '/api/v1/system/status/plugin-system') {
return fulfillJson(route, {
is_enable: true,
is_connected: true,
plugin_connector_error: '',
});
}
if (path === '/api/v1/plugins/debug-info') {
return fulfillJson(route, {
debug_url: 'ws://127.0.0.1:5300/plugin/debug',
plugin_debug_key: 'test-debug-key',
});
}
if (path === '/api/v1/box/status') {
return fulfillJson(route, {
available: true,
enabled: true,
profile: 'playwright',
recent_error_count: 0,
active_sessions: 0,
managed_processes: 0,
session_ttl_sec: 3600,
backend: {
name: 'playwright',
available: true,
},
});
}
if (path === '/api/v1/box/sessions') {
return fulfillJson(route, []);
}
if (path === '/api/v1/monitoring/data') {
return fulfillJson(route, emptyMonitoringData());
}
if (path === '/api/v1/monitoring/overview') {
return fulfillJson(route, emptyMonitoringData().overview);
}
if (path === '/api/v1/monitoring/token-statistics') {
return fulfillJson(route, emptyTokenStatistics());
}
if (path === '/api/v1/monitoring/feedback/stats') {
return fulfillJson(route, {
total_feedback: 0,
total_likes: 0,
total_dislikes: 0,
satisfaction_rate: 0,
});
}
if (path === '/api/v1/monitoring/feedback') {
return fulfillJson(route, { feedback: [], total: 0 });
}
if (path === '/api/v1/survey/pending') {
return fulfillJson(route, { survey: null });
}
if (path === '/api/v1/system/tasks') {
return fulfillJson(route, { tasks: [] });
}
if (
path === '/api/v1/marketplace/plugins' ||
path === '/api/v1/marketplace/plugins/search' ||
path === '/api/v1/marketplace/extensions/search' ||
path === '/api/v1/marketplace/mcps/search' ||
path === '/api/v1/marketplace/skills/search'
) {
return fulfillJson(route, { plugins: [], total: 0 });
}
if (path === '/api/v1/marketplace/tags') {
return fulfillJson(route, { tags: [] });
}
if (path === '/api/v1/marketplace/recommendation-lists') {
return fulfillJson(route, { lists: [] });
}
if (path === '/api/v1/dist/info/releases') {
return fulfillJson(route, []);
}
if (path === '/api/v1/dist/info/repo') {
return fulfillJson(route, {
repo: {
stargazers_count: 0,
forks_count: 0,
open_issues_count: 0,
},
contributors: [],
});
}
await fulfillJson(route, {});
}
async function handleCloudApi(route: Route) {
const path = routePath(route);
if (
path === '/api/v1/marketplace/plugins' ||
path === '/api/v1/marketplace/plugins/search' ||
path === '/api/v1/marketplace/extensions/search' ||
path === '/api/v1/marketplace/mcps/search' ||
path === '/api/v1/marketplace/skills/search'
) {
return fulfillJson(route, { plugins: [], total: 0 });
}
if (path === '/api/v1/marketplace/tags') {
return fulfillJson(route, { tags: [] });
}
if (path === '/api/v1/marketplace/recommendation-lists') {
return fulfillJson(route, { lists: [] });
}
if (path === '/api/v1/dist/info/releases') {
return fulfillJson(route, []);
}
if (path === '/api/v1/dist/info/repo') {
return fulfillJson(route, {
repo: {
stargazers_count: 0,
forks_count: 0,
open_issues_count: 0,
},
contributors: [],
});
}
await fulfillJson(route, {});
}
export async function installLangBotApiMocks(
page: Page,
options: { authenticated?: boolean; storage?: JsonRecord } = {},
) {
const { authenticated = false, storage = {} } = options;
const state: LangBotApiMockState = {
bots: [],
counters: {},
knowledgeBases: [],
mcpServers: [],
pipelines: [],
skills: [],
};
await page.addInitScript(
({ authenticated, storage }) => {
localStorage.setItem('langbot_language', 'en-US');
localStorage.setItem('extensions_group_by_type', 'false');
if (authenticated) {
localStorage.setItem('token', 'playwright-token');
localStorage.setItem('userEmail', 'admin@example.com');
} else {
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
}
for (const [key, value] of Object.entries(storage)) {
localStorage.setItem(key, String(value));
}
},
{ authenticated, storage },
);
await page.route('**/api/v1/**', (route) => handleBackendApi(route, state));
await page.route('https://space.langbot.app/**', handleCloudApi);
}
+133
View File
@@ -0,0 +1,133 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const appRoutes = [
{
path: '/home/bots',
heading: 'Bots',
bodyText: 'Select a bot from the sidebar',
},
{
path: '/home/pipelines',
heading: 'Pipelines',
bodyText: 'Select a pipeline from the sidebar',
},
{
path: '/home/extensions',
heading: 'Extensions',
bodyText: 'No extensions installed',
},
{
path: '/home/mcp',
heading: 'MCP',
bodyText: 'Select an MCP server from the sidebar',
},
{
path: '/home/knowledge',
heading: 'Knowledge',
bodyText: 'Select a knowledge base from the sidebar',
},
];
test.describe('authenticated app shell', () => {
for (const route of appRoutes) {
test(`${route.path} renders without a backend process`, async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto(route.path);
await expect(page).toHaveURL(new RegExp(`${route.path}$`));
await expect(page.getByText('Home').first()).toBeVisible();
await expect(
page.getByRole('button', { name: 'Dashboard' }),
).toBeVisible();
await expect(page.getByText('Extensions').first()).toBeVisible();
await expect(page.getByText(route.heading).first()).toBeVisible();
await expect(page.getByText(route.bodyText)).toBeVisible();
await expect(page.getByText('Backend unavailable')).toHaveCount(0);
});
}
test('/home/monitoring loads dashboard data from mocked APIs', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/monitoring');
await expect(page).toHaveURL(/\/home\/monitoring$/);
await expect(page.getByText('Total Messages').first()).toBeVisible();
await expect(
page.getByRole('tab', { name: 'Message Records' }),
).toBeVisible();
await expect(
page.getByRole('tab', { name: 'Token Monitoring' }),
).toBeVisible();
await page.getByRole('tab', { name: 'Token Monitoring' }).click();
await expect(
page.getByText('No token usage in the selected time range'),
).toBeVisible();
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
});
test('/home/extensions shows plugin debug information from the backend', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/extensions');
await page.getByRole('button', { name: 'Debug Info' }).click();
await expect(page.getByText('Plugin Debug Information')).toBeVisible();
await expect(page.getByRole('textbox').nth(0)).toHaveValue(
'ws://127.0.0.1:5300/plugin/debug',
);
await expect(page.getByRole('textbox').nth(1)).toHaveValue(
'test-debug-key',
);
});
test('/home/skills?action=create creates a manual skill', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/skills?action=create');
await expect(page).toHaveURL(/\/home\/skills\?action=create$/);
await expect(page.getByText('Create Skill').first()).toBeVisible();
await expect(page.getByText('Import Local Skill Directory')).toBeVisible();
const saveButton = page.getByRole('button', { name: 'Save' });
await expect(saveButton).toBeEnabled();
await saveButton.click();
await expect(page.getByText('Skill name cannot be empty')).toBeVisible();
await page.locator('#display_name').fill('Daily Summary');
await page.locator('#name').fill('daily_summary');
await page
.locator('#description')
.fill('Summarizes the current conversation for handoff.');
await page
.locator('#instructions')
.fill('Summarize the conversation in five concise bullet points.');
await saveButton.click();
await expect(page).toHaveURL(/\/home\/skills\?id=daily_summary$/);
await expect(
page.getByRole('heading', { name: 'Daily Summary' }),
).toBeVisible();
await expect(page.locator('#name')).toHaveValue('daily_summary');
await expect(page.locator('#description')).toHaveValue(
'Summarizes the current conversation for handoff.',
);
await expect(page.locator('#instructions')).toHaveValue(
'Summarize the conversation in five concise bullet points.',
);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('local account login reaches the authenticated home shell', async ({
page,
}) => {
await installLangBotApiMocks(page);
await page.goto('/login');
await expect(page.getByText('Welcome')).toBeVisible();
await page.getByPlaceholder('Enter email address').fill('admin@example.com');
await page.getByPlaceholder('Enter password').fill('password');
await page.getByRole('button', { name: 'Login with password' }).click();
await expect(page).toHaveURL(/\/home$/);
await expect(page.getByText('Home').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('Total Messages').first()).toBeVisible();
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
});