Merge remote-tracking branch 'origin/master' into feature/itchat-adapter

# Conflicts:
#	src/langbot/pkg/api/http/controller/groups/platform/adapters.py
#	src/langbot/pkg/plugin/connector.py
#	uv.lock
#	web/src/app/home/bots/BotDetailContent.tsx
#	web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
#	web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
This commit is contained in:
fdc310
2026-08-12 16:44:33 +08:00
587 changed files with 108423 additions and 11182 deletions
@@ -58,19 +58,9 @@ export default function AccountSettingsPanel({
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,
);
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
@@ -40,12 +40,20 @@ import { PanelToolbar } from '../settings-dialog/panel-layout';
interface ApiKey {
id: number;
uuid: string;
name: string;
key: string;
description: string;
scopes: string[];
status: 'active' | 'revoked';
secret_available: false;
created_at: string;
}
type CreatedApiKey = Omit<ApiKey, 'secret_available'> & {
key: string;
secret_available: true;
};
interface Webhook {
id: number;
name: string;
@@ -71,7 +79,7 @@ export default function ApiIntegrationPanel({
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyDescription, setNewKeyDescription] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
// Webhook state
@@ -135,7 +143,7 @@ export default function ApiIntegrationPanel({
const response = (await backendClient.post('/api/v1/apikeys', {
name: newKeyName,
description: newKeyDescription,
})) as { key: ApiKey };
})) as { key: CreatedApiKey };
setCreatedKey(response.key);
toast.success(t('common.apiKeyCreated'));
@@ -184,11 +192,6 @@ export default function ApiIntegrationPanel({
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
};
const maskApiKey = (key: string) => {
if (key.length <= 8) return key;
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;
};
// Webhook methods
const loadWebhooks = async () => {
setLoading(true);
@@ -337,25 +340,12 @@ export default function ApiIntegrationPanel({
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
</code>
<span className="text-sm text-muted-foreground">
{t('common.apiKeyStoredSecurely')}
</span>
</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"
@@ -15,6 +15,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
@@ -24,7 +25,15 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Check, Globe, Info, QrCode } from 'lucide-react';
import {
Copy,
Check,
Globe,
Info,
QrCode,
Download,
ExternalLink,
} from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
Tooltip,
@@ -33,6 +42,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
/**
* Resolve the value referenced by a `show_if.field` string.
@@ -134,6 +144,7 @@ function getValueSchema(spec: DynamicFormValueSpec) {
return z.object({
primary: z.string(),
fallbacks: z.array(z.string()),
reasoning: z.record(z.string()),
});
case DynamicFormItemType.PROMPT_EDITOR:
return z.array(
@@ -291,6 +302,52 @@ function WebhookUrlField({
);
}
function DownloadLinkField({
label,
description,
url,
filename,
helpUrl,
helpLabel,
}: {
label: string;
description?: string;
url: string;
filename?: string;
helpUrl?: string | null;
helpLabel: string;
}) {
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`;
return (
<FormItem className="min-w-0">
<FormLabel className="break-words">{label}</FormLabel>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm">
<a href={downloadUrl} download={filename}>
<Download className="h-4 w-4" />
{label}
</a>
</Button>
{helpUrl && (
<Button asChild variant="ghost" size="sm">
<a href={helpUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
{helpLabel}
</a>
</Button>
)}
</div>
{description && (
<p className="max-w-2xl text-sm break-words text-muted-foreground">
{description}
</p>
)}
</FormItem>
);
}
/**
* Display-only component for `__system.*` fields (e.g. the deployment's
* outbound IPs that the operator must add to a platform's trusted-IP list).
@@ -405,7 +462,7 @@ export default function DynamicFormComponent({
}) {
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// Normalize a form value according to its field type.
// This ensures legacy/malformed data (e.g. a plain string for
@@ -432,12 +489,24 @@ export default function DynamicFormComponent({
(v): v is string => typeof v === 'string',
)
: [],
reasoning:
obj.reasoning != null &&
typeof obj.reasoning === 'object' &&
!Array.isArray(obj.reasoning)
? Object.fromEntries(
Object.entries(obj.reasoning).filter(
(entry): entry is [string, string] =>
typeof entry[1] === 'string',
),
)
: {},
};
}
// Legacy string format or any other unexpected type
return {
primary: typeof value === 'string' ? value : '',
fallbacks: [],
reasoning: {},
};
}
if (item.type === 'prompt-editor') {
@@ -460,6 +529,7 @@ export default function DynamicFormComponent({
item.type !== 'webhook-url' &&
item.type !== 'embed-code' &&
item.type !== 'qr-code-login' &&
item.type !== 'download-link' &&
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
),
[itemConfigList],
@@ -575,12 +645,9 @@ export default function DynamicFormComponent({
// even if the user saves without modifying any field.
// form.watch(callback) only fires on subsequent changes, not on mount.
const formValues = form.getValues();
const initialFinalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
const initialFinalValues = normalizeDynamicFormValuesForSave(
editableValueSpecs,
formValues as Record<string, unknown>,
);
onSubmitRef.current?.(initialFinalValues);
@@ -595,12 +662,9 @@ export default function DynamicFormComponent({
const subscription = form.watch(() => {
const formValues = form.getValues();
const finalValues = editableValueSpecs.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, object>,
const finalValues = normalizeDynamicFormValuesForSave(
editableValueSpecs,
formValues as Record<string, unknown>,
);
onSubmitRef.current?.(finalValues);
previousInitialValues.current = finalValues as Record<string, object>;
@@ -780,6 +844,30 @@ export default function DynamicFormComponent({
);
}
if (config.type === 'download-link') {
if (!config.url) return null;
return (
<DownloadLinkField
key={config.id}
label={extractI18nObject(config.label)}
description={
config.description
? extractI18nObject(config.description)
: undefined
}
url={config.url}
filename={config.download_filename}
helpUrl={getAdapterDocUrl(config.help_links, i18n.language)}
helpLabel={
config.help_label
? extractI18nObject(config.help_label)
: t('bots.viewAdapterDocs')
}
/>
);
}
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
if (config.type === 'qr-code-login') {
return (
@@ -25,6 +25,7 @@ import {
EmbeddingModel,
RerankModel,
PluginTool,
ReasoningLevel,
} from '@/app/infra/entities/api';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
@@ -66,17 +67,18 @@ import SettingsDialog, {
} from '@/app/home/components/settings-dialog/SettingsDialog';
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
import ReasoningLevelPicker, {
REASONING_LEVELS,
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
const EMPTY_SELECT_ITEM_VALUE = '__langbot_empty_select_item_value__';
function toSelectValue(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function hasNonEmptyUuid<T extends { uuid?: string | null }>(
function hasUsableUuid<T extends { uuid?: string | null }>(
item: T,
): item is T & { uuid: string } {
return typeof item.uuid === 'string' && item.uuid.length > 0;
return typeof item.uuid === 'string' && item.uuid.trim().length > 0;
}
function hasUsableOptionName(option: { name?: string | null }): boolean {
return typeof option.name === 'string' && option.name.trim().length > 0;
}
export default function DynamicFormItemComponent({
@@ -116,7 +118,7 @@ export default function DynamicFormItemComponent({
httpClient
.getProviderLLMModels()
.then((resp) => {
setLlmModels(resp.models);
setLlmModels(resp.models.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('models.getModelListError') + err.msg);
@@ -127,7 +129,7 @@ export default function DynamicFormItemComponent({
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
setEmbeddingModels(resp.models);
setEmbeddingModels(resp.models.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg);
@@ -138,7 +140,7 @@ export default function DynamicFormItemComponent({
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
setRerankModels(resp.models.filter(hasUsableUuid));
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
@@ -190,15 +192,10 @@ export default function DynamicFormItemComponent({
const handleSpaceLogin = () => {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
httpClient
.getSpaceAuthorizeUrl(redirectUri, token)
.getSpaceBindAuthorizeUrl(redirectUri)
.then((response) => {
window.location.href = response.authorize_url;
})
@@ -242,7 +239,7 @@ export default function DynamicFormItemComponent({
httpClient
.getKnowledgeBases()
.then((resp) => {
setKnowledgeBases(resp.bases);
setKnowledgeBases(resp.bases.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
@@ -255,7 +252,7 @@ export default function DynamicFormItemComponent({
httpClient
.getBots()
.then((resp) => {
setBots(resp.bots);
setBots(resp.bots.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('bots.getBotListError') + err.msg);
@@ -392,33 +389,19 @@ export default function DynamicFormItemComponent({
</div>
);
case DynamicFormItemType.SELECT: {
const hasEmptyOption =
config.options?.some((option) => option.name === '') ?? false;
const selectValue =
hasEmptyOption && field.value === ''
? EMPTY_SELECT_ITEM_VALUE
: toSelectValue(field.value);
case DynamicFormItemType.SELECT:
return (
<Select
value={selectValue}
onValueChange={(value) =>
field.onChange(value === EMPTY_SELECT_ITEM_VALUE ? '' : value)
}
>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-full max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('common.select')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{config.options?.map((option, index) => (
{config.options?.filter(hasUsableOptionName).map((option) => (
<SelectItem
key={`${option.name}-${index}`}
value={
option.name === '' ? EMPTY_SELECT_ITEM_VALUE : option.name
}
description={option.name || undefined}
key={option.name}
value={option.name}
description={option.name}
>
{extractI18nObject(option.label)}
</SelectItem>
@@ -427,7 +410,6 @@ export default function DynamicFormItemComponent({
</SelectContent>
</Select>
);
}
case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Separate space models from regular models
@@ -482,7 +464,7 @@ export default function DynamicFormItemComponent({
{Object.entries(groupedModels).map(([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.filter(hasNonEmptyUuid).map((model) => (
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
@@ -586,7 +568,7 @@ export default function DynamicFormItemComponent({
{providerName}
</span>
</SelectLabel>
{models.filter(hasNonEmptyUuid).map((model) => (
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
@@ -683,7 +665,7 @@ export default function DynamicFormItemComponent({
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.filter(hasNonEmptyUuid).map((model) => (
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
@@ -775,7 +757,7 @@ export default function DynamicFormItemComponent({
{providerName}
</span>
</SelectLabel>
{models.filter(hasNonEmptyUuid).map((model) => (
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
@@ -840,7 +822,7 @@ export default function DynamicFormItemComponent({
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.filter(hasNonEmptyUuid).map((model) => (
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
@@ -896,7 +878,11 @@ export default function DynamicFormItemComponent({
];
const rawModelValue = field.value;
const modelValue: { primary: string; fallbacks: string[] } =
const modelValue: {
primary: string;
fallbacks: string[];
reasoning: Record<string, ReasoningLevel>;
} =
rawModelValue != null &&
typeof rawModelValue === 'object' &&
!Array.isArray(rawModelValue)
@@ -915,10 +901,29 @@ export default function DynamicFormItemComponent({
.fallbacks as unknown[]
).filter((v): v is string => typeof v === 'string')
: [],
reasoning:
(rawModelValue as Record<string, unknown>).reasoning != null &&
typeof (rawModelValue as Record<string, unknown>).reasoning ===
'object' &&
!Array.isArray(
(rawModelValue as Record<string, unknown>).reasoning,
)
? (Object.fromEntries(
Object.entries(
(rawModelValue as Record<string, unknown>)
.reasoning as Record<string, unknown>,
).filter(
(entry): entry is [string, ReasoningLevel] =>
typeof entry[1] === 'string' &&
REASONING_LEVELS.includes(entry[1] as ReasoningLevel),
),
) as Record<string, ReasoningLevel>)
: {},
}
: {
primary: typeof rawModelValue === 'string' ? rawModelValue : '',
fallbacks: [],
reasoning: {},
};
const renderModelSelect = (
@@ -935,7 +940,7 @@ export default function DynamicFormItemComponent({
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.filter(hasNonEmptyUuid).map((model) => (
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
@@ -1040,7 +1045,7 @@ export default function DynamicFormItemComponent({
{providerName}
</span>
</SelectLabel>
{models.filter(hasNonEmptyUuid).map((model) => (
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
@@ -1065,20 +1070,79 @@ export default function DynamicFormItemComponent({
field.onChange({ ...modelValue, ...patch });
};
const updateModelReasoning = (
modelUuid: string,
level: ReasoningLevel,
) => {
if (!modelUuid) return;
const updated = { ...modelValue.reasoning };
if (level === 'provider_default') {
delete updated[modelUuid];
} else {
updated[modelUuid] = level;
}
updateValue({ reasoning: updated });
};
const replaceModel = (
currentUuid: string,
nextUuid: string,
patch: Partial<typeof modelValue>,
) => {
const nextValue = { ...modelValue, ...patch };
const updatedReasoning = { ...modelValue.reasoning };
const currentModelStillSelected =
nextValue.primary === currentUuid ||
nextValue.fallbacks.includes(currentUuid);
if (
currentUuid &&
currentUuid !== nextUuid &&
!currentModelStillSelected
) {
delete updatedReasoning[currentUuid];
}
updateValue({ ...nextValue, reasoning: updatedReasoning });
};
const renderReasoningPicker = (modelUuid: string) => {
if (!modelUuid) return null;
const model = llmModels.find(
(candidate) => candidate.uuid === modelUuid,
);
const currentLevel =
modelValue.reasoning[modelUuid] || 'provider_default';
const availableLevels = model?.reasoning_capabilities?.levels || [
'provider_default',
];
const levels = REASONING_LEVELS.filter(
(level) => availableLevels.includes(level) || level === currentLevel,
);
return (
<ReasoningLevelPicker
value={currentLevel}
levels={levels}
onChange={(level) => updateModelReasoning(modelUuid, level)}
/>
);
};
const addFallbackModel = () => {
updateValue({ fallbacks: [...modelValue.fallbacks, ''] });
};
const updateFallbackModel = (index: number, value: string) => {
const updated = [...modelValue.fallbacks];
const currentUuid = updated[index];
updated[index] = value;
updateValue({ fallbacks: updated });
replaceModel(currentUuid, value, { fallbacks: updated });
};
const removeFallbackModel = (index: number) => {
const updated = [...modelValue.fallbacks];
const removedUuid = updated[index];
updated.splice(index, 1);
updateValue({ fallbacks: updated });
replaceModel(removedUuid, '', { fallbacks: updated });
};
const moveFallbackModel = (index: number, direction: 'up' | 'down') => {
@@ -1103,10 +1167,12 @@ export default function DynamicFormItemComponent({
<div className="min-w-0 flex-1">
{renderModelSelect(
modelValue.primary,
(val) => updateValue({ primary: val }),
(val) =>
replaceModel(modelValue.primary, val, { primary: val }),
t('models.selectModel'),
)}
</div>
{renderReasoningPicker(modelValue.primary)}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -1140,15 +1206,18 @@ export default function DynamicFormItemComponent({
</p>
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
<div key={index} className="flex min-w-0 items-center gap-2">
<span className="text-xs text-muted-foreground w-4 shrink-0">
<span className="w-4 shrink-0 text-xs text-muted-foreground">
{index + 1}.
</span>
<div className="min-w-0 flex-1">
{renderModelSelect(
fbUuid,
(val) => updateFallbackModel(index, val),
t('models.selectModel'),
)}
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<div className="min-w-0 flex-1">
{renderModelSelect(
fbUuid,
(val) => updateFallbackModel(index, val),
t('models.selectModel'),
)}
</div>
{renderReasoningPicker(fbUuid)}
</div>
<div className="flex gap-1 shrink-0">
<Button
@@ -1203,7 +1272,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
// Group KBs by Knowledge Engine name
const kbsByEngine = knowledgeBases.reduce(
const validKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
const kbsByEngine = validKnowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name)
@@ -1214,7 +1284,7 @@ export default function DynamicFormItemComponent({
acc[engineName].push(kb);
return acc;
},
{} as Record<string, typeof knowledgeBases>,
{} as Record<string, typeof validKnowledgeBases>,
);
return (
@@ -1222,7 +1292,7 @@ export default function DynamicFormItemComponent({
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
{field.value && field.value !== '__none__' ? (
(() => {
const selectedKb = knowledgeBases.find(
const selectedKb = validKnowledgeBases.find(
(kb) => kb.uuid === field.value,
);
return (
@@ -1250,7 +1320,7 @@ export default function DynamicFormItemComponent({
{Object.entries(kbsByEngine).map(([engineName, kbs]) => (
<SelectGroup key={engineName}>
<SelectLabel>{engineName}</SelectLabel>
{kbs.filter(hasNonEmptyUuid).map((base) => (
{kbs.map((base) => (
<SelectItem key={base.uuid} value={base.uuid}>
<div className="flex items-center gap-2">
{base.emoji && (
@@ -1268,7 +1338,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
// Group KBs by Knowledge Engine name for multi-selector
const multiKbsByEngine = knowledgeBases.reduce(
const validMultiKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
const multiKbsByEngine = validMultiKnowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name)
@@ -1279,7 +1350,7 @@ export default function DynamicFormItemComponent({
acc[engineName].push(kb);
return acc;
},
{} as Record<string, typeof knowledgeBases>,
{} as Record<string, typeof validMultiKnowledgeBases>,
);
return (
@@ -1288,7 +1359,7 @@ export default function DynamicFormItemComponent({
{field.value && field.value.length > 0 ? (
<div className="min-w-0 space-y-2">
{field.value.map((kbId: string) => {
const currentKb = knowledgeBases.find(
const currentKb = validMultiKnowledgeBases.find(
(base) => base.uuid === kbId,
);
if (!currentKb) return null;
@@ -1374,15 +1445,13 @@ export default function DynamicFormItemComponent({
{engineName}
</div>
{kbs.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
const isSelected = tempSelectedKBIds.includes(base.uuid);
return (
<div
key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
const kbId = base.uuid ?? '';
const kbId = base.uuid;
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
@@ -1444,7 +1513,7 @@ export default function DynamicFormItemComponent({
</SelectTrigger>
<SelectContent>
<SelectGroup>
{bots.filter(hasNonEmptyUuid).map((bot) => (
{bots.filter(hasUsableUuid).map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid}>
{bot.name}
</SelectItem>
@@ -18,6 +18,10 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
options?: IDynamicFormItemOption[];
show_if?: IShowIfCondition;
login_platform?: string;
url?: string;
download_filename?: string;
help_links?: Record<string, string>;
help_label?: I18nObject;
constructor(params: IDynamicFormItemSchema) {
this.id = params.id;
@@ -30,6 +34,10 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
this.options = params.options;
this.show_if = params.show_if;
this.login_platform = params.login_platform;
this.url = params.url;
this.download_filename = params.download_filename;
this.help_links = params.help_links;
this.help_label = params.help_label;
}
}
@@ -0,0 +1,79 @@
import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
export type DynamicFormSaveValueSpec = Pick<
IDynamicFormItemSchema,
'default' | 'name' | 'type'
>;
const reasoningLevels = new Set([
'disabled',
'enabled',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
]);
function normalizeModelFallbackValue(value: unknown): {
primary: string;
fallbacks: string[];
reasoning: Record<string, string>;
} {
const raw =
value != null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const primary =
typeof raw.primary === 'string'
? raw.primary
: typeof value === 'string'
? value
: '';
const fallbacks = Array.isArray(raw.fallbacks)
? raw.fallbacks.filter(
(fallback): fallback is string => typeof fallback === 'string',
)
: [];
const selectedModels = new Set([primary, ...fallbacks].filter(Boolean));
const rawReasoning =
raw.reasoning != null &&
typeof raw.reasoning === 'object' &&
!Array.isArray(raw.reasoning)
? (raw.reasoning as Record<string, unknown>)
: {};
const reasoning = Object.fromEntries(
Object.entries(rawReasoning).filter(
([modelUuid, level]) =>
selectedModels.has(modelUuid) &&
typeof level === 'string' &&
reasoningLevels.has(level),
),
) as Record<string, string>;
return { primary, fallbacks, reasoning };
}
/**
* Build the value snapshot emitted to parent forms for persistence.
* Only single-line string fields trim surrounding whitespace; multiline text
* and every other dynamic form field type preserve their original values.
*/
export function normalizeDynamicFormValuesForSave(
specs: readonly DynamicFormSaveValueSpec[],
formValues: Record<string, unknown>,
): Record<string, unknown> {
return specs.reduce<Record<string, unknown>>((values, spec) => {
const value = formValues[spec.name] ?? spec.default;
if (spec.type === 'model-fallback-selector') {
values[spec.name] = normalizeModelFallbackValue(value);
} else {
values[spec.name] =
spec.type === 'string' && typeof value === 'string'
? value.trim()
: value;
}
return values;
}, {});
}
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/form';
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
import { extractI18nObject } from '@/i18n/I18nProvider';
/**
@@ -150,12 +151,9 @@ export default function N8nAuthFormComponent({
// Emit initial form values on mount so the parent form's
// initializedStagesRef registers this stage (matches DynamicFormComponent).
const formValues = form.getValues();
const initialFinalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, string>,
const initialFinalValues = normalizeDynamicFormValuesForSave(
itemConfigList,
formValues as Record<string, unknown>,
);
onSubmitRef.current?.(initialFinalValues);
previousInitialValues.current = initialFinalValues as Record<
@@ -171,12 +169,9 @@ export default function N8nAuthFormComponent({
// 获取完整的表单值,确保包含所有默认值
const formValues = form.getValues();
const finalValues = itemConfigList.reduce(
(acc, item) => {
acc[item.name] = formValues[item.name] ?? item.default;
return acc;
},
{} as Record<string, string>,
const finalValues = normalizeDynamicFormValuesForSave(
itemConfigList,
formValues as Record<string, unknown>,
);
onSubmitRef.current?.(finalValues);
@@ -4,7 +4,12 @@ import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
import langbotIcon from '@/app/assets/langbot-logo.webp';
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
import { getCloudServiceClientSync } from '@/app/infra/http';
import {
clearUserInfo,
getCloudServiceClientSync,
useCurrentWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http';
import { useTranslation } from 'react-i18next';
import {
Moon,
@@ -28,10 +33,10 @@ import {
Zap,
FilePlus2,
Sparkles,
HardDrive,
Server,
Puzzle,
RefreshCcw,
UsersRound,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
@@ -57,6 +62,9 @@ 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 WorkspaceSwitcher, {
OPEN_WORKSPACE_SETTINGS_EVENT,
} from '@/app/home/components/workspace-settings/WorkspaceSwitcher';
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
import SettingsDialog, {
SettingsSection,
@@ -101,6 +109,11 @@ import {
import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
import { FeedbackPopoverContent } from './FeedbackPopover';
import {
type WorkspaceQuotaItem,
useWorkspaceQuotaStatus,
} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
// Compare two version strings, returns true if v1 > v2
function compareVersions(v1: string, v2: string): boolean {
@@ -264,6 +277,56 @@ function saveListExpansionState(state: SidebarListExpansionState) {
// Maximum number of entity sub-items visible before "More" toggle
const MAX_VISIBLE_ITEMS = 5;
const MCP_REFRESH_POLL_INTERVAL_MS = 1000;
const MCP_REFRESH_TIMEOUT_MS = 60000;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const UNLIMITED_QUOTA: WorkspaceQuotaItem = {
count: 0,
max: -1,
reached: false,
loading: false,
disabled: false,
};
async function waitForMCPRefreshTask(taskId: number) {
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
while (Date.now() < deadline) {
const task = await httpClient.getAsyncTask(taskId);
if (task.runtime.done) return task;
await sleep(MCP_REFRESH_POLL_INTERVAL_MS);
}
throw new Error(`Timed out waiting for MCP refresh task ${taskId}`);
}
async function refreshEnabledMCPConnections() {
const resp = await httpClient.getMCPServers();
const enabledServers = resp.servers.filter((server) => server.enable);
if (enabledServers.length === 0) return;
const taskResults = await Promise.allSettled(
enabledServers.map((server) => httpClient.testMCPServer(server.name, {})),
);
const taskIds: number[] = [];
for (const result of taskResults) {
if (
result.status === 'fulfilled' &&
typeof result.value.task_id === 'number'
) {
taskIds.push(result.value.task_id);
} else if (result.status === 'rejected') {
console.error('Failed to start MCP refresh task:', result.reason);
}
}
await Promise.allSettled(taskIds.map(waitForMCPRefreshTask));
}
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
@@ -336,8 +399,14 @@ function NavItems({
const pathname = location.pathname;
const [searchParams] = useSearchParams();
const sidebarData = useSidebarData();
const quotaStatus = useWorkspaceQuotaStatus();
const { state: sidebarState, isMobile } = useSidebar();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManageResources =
currentWorkspace?.permissions.includes('resource.manage') ?? false;
const canOperateRuntime =
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
// Track which entity categories have their full list expanded
const [expandedLists, setExpandedLists] = useState<SidebarListExpansionState>(
loadListExpansionState,
@@ -352,11 +421,19 @@ function NavItems({
if (extRefreshing) return;
setExtRefreshing(true);
try {
await Promise.all([
const results = await Promise.allSettled([
sidebarData.refreshPlugins(),
sidebarData.refreshMCPServers(),
sidebarData.refreshSkills(),
refreshEnabledMCPConnections(),
]);
const mcpRefreshResult = results[2];
if (mcpRefreshResult.status === 'rejected') {
console.error(
'Failed to refresh MCP connections:',
mcpRefreshResult.reason,
);
}
await sidebarData.refreshMCPServers();
} finally {
setExtRefreshing(false);
}
@@ -463,7 +540,10 @@ function NavItems({
<>
{sectionItems.map((config) => {
if (!isEntityCategory(config.id)) {
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
if (config.id === 'add-extension' && !canManageResources) {
return null;
}
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
return (
<SidebarMenuItem key={config.id}>
<SidebarMenuButton
@@ -502,12 +582,25 @@ function NavItems({
: sidebarData[entityKey];
const routePrefix = ENTITY_ROUTE_MAP[categoryId];
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(categoryId);
const canCreate = CREATABLE_CATEGORIES.includes(categoryId);
const canCreate =
canManageResources && CREATABLE_CATEGORIES.includes(categoryId);
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(categoryId);
const isPlugin = categoryId === 'plugins';
const isSkill = categoryId === 'skills';
const isBot = categoryId === 'bots';
const isMCP = categoryId === 'mcp';
const quota =
categoryId === 'bots'
? quotaStatus.bots
: categoryId === 'pipelines'
? quotaStatus.pipelines
: categoryId === 'knowledge'
? quotaStatus.knowledgeBases
: categoryId === 'plugins' ||
categoryId === 'mcp' ||
categoryId === 'skills'
? quotaStatus.extensions
: UNLIMITED_QUOTA;
const resolveItemRoute = (item: SidebarEntityItem): string => {
if (item.extensionType === 'mcp') {
@@ -750,6 +843,7 @@ function NavItems({
{itemIsPluginType && !item.debug && (
<PluginItemMenu
item={item}
canManage={canManageResources}
onUpdate={() => handlePluginUpdate(item)}
onDelete={() => handlePluginDelete(item)}
/>
@@ -839,128 +933,144 @@ function NavItems({
>
<div className="flex items-center justify-between mb-1 px-2">
<span className="text-sm font-medium">{config.name}</span>
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
{canCreate && (
<WorkspaceQuotaTooltip
quota={quota}
resource={config.name}
side="right"
>
{isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={() => {
navigate(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Plus className="size-3.5" />
</button>
))}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
onClick={() => {
navigate(`${routePrefix}?id=new`);
setPopoverOpen((prev) => ({
...prev,
[config.id]: false,
}));
}}
>
<Plus className="size-3.5" />
</button>
)}
</WorkspaceQuotaTooltip>
)}
</div>
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
{renderEntityList(true)}
@@ -1013,7 +1123,7 @@ function NavItems({
{config.name}
</span>
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{isExtensionsCategory && (
{isExtensionsCategory && canOperateRuntime && (
<button
type="button"
title={t('common.refresh', '刷新')}
@@ -1028,103 +1138,119 @@ function NavItems({
/>
</button>
)}
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
{canCreate && (
<WorkspaceQuotaTooltip
quota={quota}
resource={config.name}
side="right"
>
{isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension');
navigate('/home/add-extension?manual=1');
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
</button>
))}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : isSkill ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/skills?action=create');
}}
>
<FilePlus2 className="size-4" />
{t('skills.createManually')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Upload className="size-4" />
{t('skills.uploadZip')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/add-extension?manual=1');
}}
>
<Github className="size-4" />
{t('skills.importFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
disabled={quota.disabled}
aria-disabled={quota.disabled}
aria-label={`${t('common.create')} ${config.name}`}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
</button>
)}
</WorkspaceQuotaTooltip>
)}
<CollapsibleTrigger asChild>
<button
type="button"
@@ -1280,10 +1406,12 @@ function NavItems({
// Dropdown menu for plugin sidebar sub-items (shown on hover)
function PluginItemMenu({
item,
canManage,
onUpdate,
onDelete,
}: {
item: SidebarEntityItem;
canManage: boolean;
onUpdate: () => void;
onDelete: () => void;
}) {
@@ -1294,6 +1422,8 @@ function PluginItemMenu({
const isGithub = item.installSource === 'github';
const hasSourceLink = isMarketplace || isGithub;
if (!canManage && !hasSourceLink) return null;
function handleViewSource() {
const slashIdx = item.id.indexOf('/');
const author = slashIdx >= 0 ? item.id.substring(0, slashIdx) : '';
@@ -1334,7 +1464,7 @@ function PluginItemMenu({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
{isMarketplace && (
{canManage && isMarketplace && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -1363,16 +1493,18 @@ function PluginItemMenu({
<span>{t('plugins.viewSource')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:text-red-600"
onClick={() => {
onDelete();
setOpen(false);
}}
>
<Trash className="size-4" />
<span>{t('plugins.delete')}</span>
</DropdownMenuItem>
{canManage && (
<DropdownMenuItem
className="cursor-pointer text-red-600 focus:text-red-600"
onClick={() => {
onDelete();
setOpen(false);
}}
>
<Trash className="size-4" />
<span>{t('plugins.delete')}</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -1562,6 +1694,14 @@ export default function HomeSidebar({
useState<Record<string, boolean>>(loadSectionState);
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap();
const showWorkspaceSwitcher =
workspaces.length > 1 ||
currentWorkspace?.workspace.source === 'cloud_projection';
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' &&
currentWorkspace?.permissions.includes('audit.view');
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
@@ -1605,6 +1745,19 @@ export default function HomeSidebar({
});
}
useEffect(() => {
const openWorkspaceSettings = () => openSettings('workspace');
window.addEventListener(
OPEN_WORKSPACE_SETTINGS_EVENT,
openWorkspaceSettings,
);
return () =>
window.removeEventListener(
OPEN_WORKSPACE_SETTINGS_EVENT,
openWorkspaceSettings,
);
});
function handleSettingsSectionChange(section: SettingsSection) {
setSettingsSection(section);
const params = new URLSearchParams(searchParams.toString());
@@ -1628,10 +1781,6 @@ export default function HomeSidebar({
useEffect(() => {
initSelect();
if (!localStorage.getItem('token')) {
localStorage.setItem('token', 'test-token');
localStorage.setItem('userEmail', 'test@example.com');
}
const storedEmail = localStorage.getItem('userEmail');
if (storedEmail) {
@@ -1770,6 +1919,7 @@ export default function HomeSidebar({
}
function handleLogout() {
clearUserInfo();
localStorage.removeItem('token');
localStorage.removeItem('userEmail');
window.location.href = '/login';
@@ -1830,6 +1980,12 @@ export default function HomeSidebar({
</SidebarMenu>
</SidebarHeader>
{showWorkspaceSwitcher && (
<div className="px-2 group-data-[collapsible=icon]:px-0">
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
</div>
)}
{/* Navigation items grouped by section */}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
@@ -1898,18 +2054,20 @@ 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>
{/* API-key management is available only to authorized Workspace roles. */}
{currentWorkspace?.permissions.includes('api_key.manage') && (
<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>
@@ -2001,12 +2159,22 @@ export default function HomeSidebar({
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
openSettings('storageAnalysis');
openSettings('workspace');
}}
>
<HardDrive />
{t('storageAnalysis.title')}
<UsersRound />
{t('workspace.settings')}
</DropdownMenuItem>
{canViewStorageAnalysis && (
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
openSettings('storageAnalysis');
}}
>
{t('storageAnalysis.title')}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
@@ -4,6 +4,7 @@ import React, {
useState,
useEffect,
useCallback,
useRef,
} from 'react';
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
@@ -48,9 +49,11 @@ export interface SidebarDataContextValue {
pipelines: SidebarEntityItem[];
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
pluginCount: number;
mcpServers: SidebarEntityItem[];
skills: SidebarEntityItem[];
pluginPages: PluginPageItem[];
quotaDataLoaded: boolean;
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
@@ -77,9 +80,36 @@ export function SidebarDataProvider({
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [pluginCount, setPluginCount] = useState(0);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [quotaDataLoaded, setQuotaDataLoaded] = useState(false);
const refreshRequestIds = useRef({
bots: 0,
pipelines: 0,
knowledgeBases: 0,
plugins: 0,
mcpServers: 0,
skills: 0,
});
const quotaResourceLoaded = useRef({
bots: false,
pipelines: false,
knowledgeBases: false,
plugins: false,
mcpServers: false,
skills: false,
});
const setQuotaResourceLoaded = useCallback(
(resource: keyof typeof quotaResourceLoaded.current, loaded: boolean) => {
quotaResourceLoaded.current[resource] = loaded;
setQuotaDataLoaded(
Object.values(quotaResourceLoaded.current).every(Boolean),
);
},
[],
);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
useState<boolean>(() => {
@@ -96,8 +126,11 @@ export function SidebarDataProvider({
}, []);
const refreshBots = useCallback(async () => {
const requestId = ++refreshRequestIds.current.bots;
try {
const resp = await httpClient.getBots();
if (requestId !== refreshRequestIds.current.bots) return;
setQuotaResourceLoaded('bots', true);
setBots(
resp.bots.map((bot) => ({
id: bot.uuid || '',
@@ -109,13 +142,18 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.bots) return;
setQuotaResourceLoaded('bots', false);
console.error('Failed to fetch bots for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshPipelines = useCallback(async () => {
const requestId = ++refreshRequestIds.current.pipelines;
try {
const resp = await httpClient.getPipelines();
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', true);
setPipelines(
resp.pipelines.map((p) => ({
id: p.uuid || '',
@@ -126,13 +164,18 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.pipelines) return;
setQuotaResourceLoaded('pipelines', false);
console.error('Failed to fetch pipelines for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshKnowledgeBases = useCallback(async () => {
const requestId = ++refreshRequestIds.current.knowledgeBases;
try {
const resp = await httpClient.getKnowledgeBases();
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
setQuotaResourceLoaded('knowledgeBases', true);
setKnowledgeBases(
resp.bases.map((kb) => ({
id: kb.uuid || '',
@@ -143,11 +186,14 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
setQuotaResourceLoaded('knowledgeBases', false);
console.error('Failed to fetch knowledge bases for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshPlugins = useCallback(async () => {
const requestId = ++refreshRequestIds.current.plugins;
try {
const [pluginsResp, marketplaceResp] = await Promise.all([
httpClient.getPlugins(),
@@ -155,6 +201,9 @@ export function SidebarDataProvider({
.getMarketplacePlugins(1, 100)
.catch(() => ({ plugins: [] })),
]);
if (requestId !== refreshRequestIds.current.plugins) return;
setQuotaResourceLoaded('plugins', true);
setPluginCount(pluginsResp.plugins?.length ?? 0);
// Build marketplace version lookup: "author/name" -> latest_version
const marketplaceVersions = new Map<string, string>();
@@ -166,6 +215,19 @@ export function SidebarDataProvider({
// Deduplicate plugins by composite key (prefer debug over installed)
const pluginMap = new Map<string, SidebarEntityItem>();
const pluginIconURLs = new Map<string, string>(
await Promise.all(
pluginsResp.plugins.map(async (plugin) => {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const url = await httpClient
.getAuthenticatedPluginIconURL(author, name)
.catch(() => '');
return [`${author}/${name}`, url] as const;
}),
),
);
for (const plugin of pluginsResp.plugins) {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
@@ -184,7 +246,7 @@ export function SidebarDataProvider({
const item: SidebarEntityItem = {
id: compositeKey,
name: extractI18nObject(meta.label),
iconURL: httpClient.getPluginIconURL(author, name),
iconURL: pluginIconURLs.get(compositeKey) || '',
installSource: plugin.install_source,
installInfo: plugin.install_info,
hasUpdate,
@@ -218,7 +280,7 @@ export function SidebarDataProvider({
pluginAuthor: author,
pluginName: name,
pluginLabel: label,
pluginIconURL: httpClient.getPluginIconURL(author, name),
pluginIconURL: pluginIconURLs.get(`${author}/${name}`) || '',
pageId: page.id,
path: page.path,
});
@@ -228,13 +290,18 @@ export function SidebarDataProvider({
}
setPluginPages(pages);
} catch (error) {
if (requestId !== refreshRequestIds.current.plugins) return;
setQuotaResourceLoaded('plugins', false);
console.error('Failed to fetch plugins for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshMCPServers = useCallback(async () => {
const requestId = ++refreshRequestIds.current.mcpServers;
try {
const resp = await httpClient.getMCPServers();
if (requestId !== refreshRequestIds.current.mcpServers) return;
setQuotaResourceLoaded('mcpServers', true);
setMCPServers(
resp.servers.map((server) => ({
id: server.name, // Keep __ for API calls
@@ -244,13 +311,18 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.mcpServers) return;
setQuotaResourceLoaded('mcpServers', false);
console.error('Failed to fetch MCP servers for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshSkills = useCallback(async () => {
const requestId = ++refreshRequestIds.current.skills;
try {
const resp = await httpClient.getSkills();
if (requestId !== refreshRequestIds.current.skills) return;
setQuotaResourceLoaded('skills', true);
setSkills(
resp.skills.map((skill) => ({
id: skill.name,
@@ -260,11 +332,22 @@ export function SidebarDataProvider({
})),
);
} catch (error) {
if (requestId !== refreshRequestIds.current.skills) return;
setQuotaResourceLoaded('skills', false);
console.error('Failed to fetch skills for sidebar:', error);
}
}, []);
}, [setQuotaResourceLoaded]);
const refreshAll = useCallback(async () => {
quotaResourceLoaded.current = {
bots: false,
pipelines: false,
knowledgeBases: false,
plugins: false,
mcpServers: false,
skills: false,
};
setQuotaDataLoaded(false);
await Promise.all([
refreshBots(),
refreshPipelines(),
@@ -294,9 +377,11 @@ export function SidebarDataProvider({
pipelines,
knowledgeBases,
plugins,
pluginCount,
mcpServers,
skills,
pluginPages,
quotaDataLoaded,
refreshBots,
refreshPipelines,
refreshKnowledgeBases,
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Plus, Boxes } from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { ModelProvider } from '@/app/infra/entities/api';
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
import {
Dialog,
DialogContent,
@@ -15,6 +15,7 @@ import ProviderForm from './component/provider-form/ProviderForm';
import { ProviderCard } from './components';
import {
ExtraArg,
DEFAULT_REASONING_CONFIG,
ModelType,
ScanModelsResult,
SelectedScannedModel,
@@ -24,6 +25,8 @@ import {
} from './types';
import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
import { useCurrentWorkspace } from '@/app/infra/http';
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open.
@@ -83,10 +86,13 @@ export default function ModelsPanel({
onBlockingChange,
}: ModelsPanelProps) {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
const [spaceBilling, setSpaceBilling] =
useState<WorkspaceSpaceBilling | null>(null);
// Expanded providers and their models
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
@@ -140,7 +146,7 @@ export default function ModelsPanel({
useEffect(() => {
if (active) {
loadUserInfo();
loadWorkspaceBilling();
loadProviders();
loadRequesterSupportTypes();
}
@@ -163,16 +169,11 @@ export default function ModelsPanel({
}
}, [providersLoaded, providers]);
async function loadUserInfo() {
async function loadWorkspaceBilling() {
try {
const userInfo = await httpClient.getUserInfo();
setAccountType(userInfo.account_type);
if (userInfo.account_type === 'space') {
const creditsInfo = await httpClient.getSpaceCredits();
setSpaceCredits(creditsInfo.credits);
}
setSpaceBilling(await httpClient.getWorkspaceSpaceBilling());
} catch {
setAccountType('local');
setSpaceBilling(null);
}
}
@@ -270,17 +271,9 @@ export default function ModelsPanel({
async function handleSpaceLogin() {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
@@ -293,6 +286,7 @@ export default function ModelsPanel({
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
contextLength?: number | null,
) {
if (!name.trim()) {
@@ -308,6 +302,7 @@ export default function ModelsPanel({
name,
provider_uuid: providerUuid,
abilities,
reasoning_config: reasoningConfig,
context_length: parseContextLength(
contextLength,
t('models.contextLengthInvalid'),
@@ -369,6 +364,7 @@ export default function ModelsPanel({
name: item.model.name,
provider_uuid: providerUuid,
abilities: item.abilities,
reasoning_config: DEFAULT_REASONING_CONFIG,
context_length: item.model.context_length ?? null,
extra_args: {},
} as never);
@@ -406,6 +402,7 @@ export default function ModelsPanel({
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
contextLength?: number | null,
) {
if (!name.trim()) {
@@ -421,6 +418,7 @@ export default function ModelsPanel({
name,
provider_uuid: providerUuid,
abilities,
reasoning_config: reasoningConfig,
context_length: parseContextLength(
contextLength,
t('models.contextLengthInvalid'),
@@ -477,6 +475,7 @@ export default function ModelsPanel({
modelType: ModelType,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
) {
setIsTesting(true);
setTestResult(null);
@@ -499,6 +498,7 @@ export default function ModelsPanel({
provider_uuid: '',
provider: providerData,
abilities,
reasoning_config: reasoningConfig,
extra_args: extraArgsObj,
} as never);
} else if (modelType === 'embedding') {
@@ -544,13 +544,15 @@ export default function ModelsPanel({
<ProviderCard
key={provider.uuid}
provider={provider}
canManage={canManage}
isLangBotModels={isLangBotModels}
supportTypes={requesterSupportTypes[provider.requester]}
isExpanded={expandedProviders.has(provider.uuid)}
isLoading={loadingProviders.has(provider.uuid)}
models={providerModels[provider.uuid]}
accountType={accountType}
spaceCredits={spaceCredits}
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
spaceCredits={spaceBilling?.credits ?? null}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
@@ -560,13 +562,21 @@ export default function ModelsPanel({
onSpaceLogin={handleSpaceLogin}
onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}
onCloseAddModel={() => setAddModelPopoverOpen(null)}
onAddModel={(modelType, name, abilities, extraArgs, contextLength) =>
onAddModel={(
modelType,
name,
abilities,
extraArgs,
reasoningConfig,
contextLength,
) =>
handleAddModel(
provider.uuid,
modelType,
name,
abilities,
extraArgs,
reasoningConfig,
contextLength,
)
}
@@ -582,6 +592,7 @@ export default function ModelsPanel({
name,
abilities,
extraArgs,
reasoningConfig,
contextLength,
) =>
handleUpdateModel(
@@ -591,6 +602,7 @@ export default function ModelsPanel({
name,
abilities,
extraArgs,
reasoningConfig,
contextLength,
)
}
@@ -599,8 +611,15 @@ export default function ModelsPanel({
onDeleteModel={(modelId, modelType) =>
handleDeleteModel(provider.uuid, modelId, modelType)
}
onTestModel={(name, modelType, abilities, extraArgs) =>
handleTestModel(provider.uuid, name, modelType, abilities, extraArgs)
onTestModel={(name, modelType, abilities, extraArgs, reasoningConfig) =>
handleTestModel(
provider.uuid,
name,
modelType,
abilities,
extraArgs,
reasoningConfig,
)
}
isSubmitting={isSubmitting}
isTesting={isTesting}
@@ -628,10 +647,12 @@ export default function ModelsPanel({
)
: 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>
{canManage && (
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
)}
</div>
{/* Provider List */}
@@ -6,6 +6,7 @@ import {
ArrowUpDown,
Eye,
Wrench,
BrainCircuit,
Check,
RefreshCw,
} from 'lucide-react';
@@ -20,8 +21,12 @@ import {
} from '@/components/ui/popover';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useTranslation } from 'react-i18next';
import { ScannedProviderModel } from '@/app/infra/entities/api';
import {
ReasoningConfig,
ScannedProviderModel,
} from '@/app/infra/entities/api';
import {
DEFAULT_REASONING_CONFIG,
ExtraArg,
ModelType,
ScanModelsResult,
@@ -42,6 +47,7 @@ interface AddModelPopoverProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
contextLength?: number | null,
) => Promise<void>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
@@ -54,6 +60,7 @@ interface AddModelPopoverProps {
modelType: ModelType,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
) => Promise<void>;
isSubmitting: boolean;
isTesting: boolean;
@@ -143,11 +150,24 @@ export default function AddModelPopover({
tab === 'llm' && contextLength.trim()
? Number(contextLength.trim())
: null;
await onAddModel(tab, name, abilities, extraArgs, parsedContextLength);
await onAddModel(
tab,
name,
abilities,
extraArgs,
DEFAULT_REASONING_CONFIG,
parsedContextLength,
);
};
const handleTest = async () => {
await onTestModel(name, tab, tab === 'llm' ? abilities : [], extraArgs);
await onTestModel(
name,
tab,
tab === 'llm' ? abilities : [],
extraArgs,
DEFAULT_REASONING_CONFIG,
);
};
const handleScan = async () => {
@@ -322,7 +342,7 @@ export default function AddModelPopover({
{tab === 'llm' && (
<div className="space-y-2">
<Label>{t('models.abilities')}</Label>
<div className="flex gap-4">
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="add-vision"
@@ -349,6 +369,19 @@ export default function AddModelPopover({
{t('models.functionCallAbility')}
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="add-reasoning"
checked={abilities.includes('reasoning')}
onCheckedChange={(checked) =>
toggleAbility('reasoning', checked as boolean)
}
/>
<Label htmlFor="add-reasoning" className="text-sm">
<BrainCircuit className="h-3 w-3 inline mr-1" />
{t('models.reasoningAbility')}
</Label>
</div>
</div>
</div>
)}
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
import { Trash2, Eye, Wrench, Check, BrainCircuit } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -11,13 +11,23 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { useTranslation } from 'react-i18next';
import { LLMModel, EmbeddingModel } from '@/app/infra/entities/api';
import { ExtraArg, ModelType, TestResult } from '../types';
import {
LLMModel,
EmbeddingModel,
ReasoningConfig,
} from '@/app/infra/entities/api';
import {
DEFAULT_REASONING_CONFIG,
ExtraArg,
ModelType,
TestResult,
} from '../types';
import ExtraArgsEditor from './ExtraArgsEditor';
import { userInfo } from '@/app/infra/http';
interface ModelItemProps {
model: LLMModel | EmbeddingModel;
canManage: boolean;
modelType: ModelType;
isLangBotModels: boolean;
editModelPopoverOpen: string | null;
@@ -31,12 +41,14 @@ interface ModelItemProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
contextLength?: number | null,
) => Promise<void>;
onTestModel: (
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
) => Promise<void>;
isSubmitting: boolean;
isTesting: boolean;
@@ -71,6 +83,7 @@ function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
export default function ModelItem({
model,
canManage,
modelType,
isLangBotModels,
editModelPopoverOpen,
@@ -101,7 +114,6 @@ export default function ModelItem({
const [editExtraArgs, setEditExtraArgs] = useState<ExtraArg[]>(
convertExtraArgsToArray(model.extra_args),
);
const isEditOpen = editModelPopoverOpen === model.uuid;
const isDeleteOpen = deleteConfirmOpen === model.uuid;
@@ -131,12 +143,20 @@ export default function ModelItem({
editName,
editAbilities,
editExtraArgs,
modelType === 'llm'
? (model as LLMModel).reasoning_config || DEFAULT_REASONING_CONFIG
: DEFAULT_REASONING_CONFIG,
parsedContextLength,
);
};
const handleTest = async () => {
await onTestModel(editName, editAbilities, editExtraArgs);
await onTestModel(
editName,
editAbilities,
editExtraArgs,
DEFAULT_REASONING_CONFIG,
);
};
const toggleAbility = (ability: string, checked: boolean) => {
@@ -147,9 +167,15 @@ export default function ModelItem({
}
};
const supportsReasoning =
modelType === 'llm' &&
((model as LLMModel).reasoning_capabilities?.supported === true ||
(model as LLMModel).abilities?.includes('reasoning'));
const canSaveModel = !isLangBotModels;
// Check if popover should be disabled (space models when not logged in)
const isPopoverDisabled =
isLangBotModels && userInfo?.account_type !== 'space';
!canManage || (isLangBotModels && userInfo?.account_type !== 'space');
return (
<Popover
@@ -192,8 +218,14 @@ export default function ModelItem({
<Wrench className="h-3 w-3" />
</Badge>
)}
{supportsReasoning && (
<Badge variant="outline" className="text-xs gap-1">
<BrainCircuit className="h-3 w-3" />
{t('models.reasoningAbility')}
</Badge>
)}
</div>
{!isLangBotModels && (
{canManage && !isLangBotModels && (
<Popover
open={isDeleteOpen}
onOpenChange={(open) =>
@@ -268,7 +300,7 @@ export default function ModelItem({
{modelType === 'llm' && (
<div className="space-y-2">
<Label>{t('models.abilities')}</Label>
<div className="flex gap-4">
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<Checkbox
id={`edit-vision-${model.uuid}`}
@@ -303,6 +335,23 @@ export default function ModelItem({
{t('models.functionCallAbility')}
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`edit-reasoning-${model.uuid}`}
checked={editAbilities.includes('reasoning')}
disabled={isLangBotModels}
onCheckedChange={(checked) =>
toggleAbility('reasoning', checked as boolean)
}
/>
<Label
htmlFor={`edit-reasoning-${model.uuid}`}
className="text-sm"
>
<BrainCircuit className="h-3 w-3 inline mr-1" />
{t('models.reasoningAbility')}
</Label>
</div>
</div>
</div>
)}
@@ -334,7 +383,7 @@ export default function ModelItem({
/>
<div className="flex gap-2">
{!isLangBotModels && (
{canSaveModel && (
<Button
className="flex-1"
size="sm"
@@ -345,7 +394,7 @@ export default function ModelItem({
</Button>
)}
<Button
className={isLangBotModels ? 'w-full' : 'flex-1'}
className={canSaveModel ? 'flex-1' : 'w-full'}
size="sm"
variant="outline"
onClick={handleTest}
@@ -9,7 +9,7 @@ import {
Radar,
} from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { ModelProvider } from '@/app/infra/entities/api';
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
import { Button } from '@/components/ui/button';
import {
Collapsible,
@@ -38,12 +38,14 @@ import AddModelPopover from './AddModelPopover';
interface ProviderCardProps {
provider: ModelProvider;
canManage: boolean;
isLangBotModels?: boolean;
supportTypes?: string[];
isExpanded: boolean;
isLoading: boolean;
models?: ProviderModels;
accountType: 'local' | 'space';
isWorkspaceOwner: boolean;
ownerSpaceBound: boolean;
spaceCredits: number | null;
// Popover states
addModelPopoverOpen: string | null;
@@ -61,6 +63,7 @@ interface ProviderCardProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
contextLength?: number | null,
) => Promise<void>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
@@ -76,6 +79,7 @@ interface ProviderCardProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
contextLength?: number | null,
) => Promise<void>;
onOpenDeleteConfirm: (modelId: string) => void;
@@ -86,6 +90,7 @@ interface ProviderCardProps {
modelType: ModelType,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
) => Promise<void>;
isSubmitting: boolean;
isTesting: boolean;
@@ -101,12 +106,14 @@ function maskApiKey(key: string): string {
export default function ProviderCard({
provider,
canManage,
isLangBotModels = false,
supportTypes,
isExpanded,
isLoading,
models,
accountType,
isWorkspaceOwner,
ownerSpaceBound,
spaceCredits,
addModelPopoverOpen,
editModelPopoverOpen,
@@ -196,7 +203,7 @@ export default function ProviderCard({
</div>
</div>
<div className="flex items-center gap-1 ml-2 shrink-0">
{isLangBotModels && accountType !== 'space' && (
{isLangBotModels && isWorkspaceOwner && !ownerSpaceBound && (
<Button
variant="outline"
size="sm"
@@ -206,16 +213,15 @@ export default function ProviderCard({
}}
>
<LogIn className="h-4 w-4 mr-1" />
{t('models.loginWithSpace')}
{t('models.ownerMustBindSpace')}
</Button>
)}
{isLangBotModels &&
accountType === 'space' &&
spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
{isLangBotModels && ownerSpaceBound && spaceCredits !== null && (
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
<span>
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
</span>
{isWorkspaceOwner && (
<Button
variant="ghost"
size="icon"
@@ -230,9 +236,20 @@ export default function ProviderCard({
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
{!isLangBotModels && (
)}
</div>
)}
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
<span className="text-xs text-muted-foreground">
{t('models.usesOwnerSpaceBilling')}
</span>
)}
{isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && (
<span className="text-xs text-muted-foreground">
{t('models.ownerMustBindSpace')}
</span>
)}
{canManage && !isLangBotModels && (
<>
<Button
variant="ghost"
@@ -317,7 +334,7 @@ export default function ProviderCard({
) : (
<div />
)}
{!isLangBotModels && (
{canManage && !isLangBotModels && (
<div className="flex items-center gap-1">
<AddModelPopover
isOpen={
@@ -404,6 +421,7 @@ export default function ProviderCard({
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="llm"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
@@ -417,6 +435,7 @@ export default function ProviderCard({
name,
abilities,
extraArgs,
reasoningConfig,
contextLength,
) =>
onUpdateModel(
@@ -425,11 +444,23 @@ export default function ProviderCard({
name,
abilities,
extraArgs,
reasoningConfig,
contextLength,
)
}
onTestModel={(name, abilities, extraArgs) =>
onTestModel(name, 'llm', abilities, extraArgs)
onTestModel={(
name,
abilities,
extraArgs,
reasoningConfig,
) =>
onTestModel(
name,
'llm',
abilities,
extraArgs,
reasoningConfig,
)
}
isSubmitting={isSubmitting}
isTesting={isTesting}
@@ -441,6 +472,7 @@ export default function ProviderCard({
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="embedding"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
@@ -450,17 +482,34 @@ export default function ProviderCard({
onOpenDeleteConfirm={onOpenDeleteConfirm}
onCloseDeleteConfirm={onCloseDeleteConfirm}
onDeleteModel={() => onDeleteModel(model.uuid, 'embedding')}
onUpdateModel={(name, abilities, extraArgs) =>
onUpdateModel={(
name,
abilities,
extraArgs,
reasoningConfig,
) =>
onUpdateModel(
model.uuid,
'embedding',
name,
abilities,
extraArgs,
reasoningConfig,
)
}
onTestModel={(name, abilities, extraArgs) =>
onTestModel(name, 'embedding', abilities, extraArgs)
onTestModel={(
name,
abilities,
extraArgs,
reasoningConfig,
) =>
onTestModel(
name,
'embedding',
abilities,
extraArgs,
reasoningConfig,
)
}
isSubmitting={isSubmitting}
isTesting={isTesting}
@@ -472,6 +521,7 @@ export default function ProviderCard({
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="rerank"
isLangBotModels={isLangBotModels}
editModelPopoverOpen={editModelPopoverOpen}
@@ -481,17 +531,34 @@ export default function ProviderCard({
onOpenDeleteConfirm={onOpenDeleteConfirm}
onCloseDeleteConfirm={onCloseDeleteConfirm}
onDeleteModel={() => onDeleteModel(model.uuid, 'rerank')}
onUpdateModel={(name, abilities, extraArgs) =>
onUpdateModel={(
name,
abilities,
extraArgs,
reasoningConfig,
) =>
onUpdateModel(
model.uuid,
'rerank',
name,
abilities,
extraArgs,
reasoningConfig,
)
}
onTestModel={(name, abilities, extraArgs) =>
onTestModel(name, 'rerank', abilities, extraArgs)
onTestModel={(
name,
abilities,
extraArgs,
reasoningConfig,
) =>
onTestModel(
name,
'rerank',
abilities,
extraArgs,
reasoningConfig,
)
}
isSubmitting={isSubmitting}
isTesting={isTesting}
@@ -5,6 +5,7 @@ import {
ModelProvider,
ProviderScanDebugInfo,
ScannedProviderModel,
ReasoningConfig,
} from '@/app/infra/entities/api';
export type ExtraArg = {
@@ -16,6 +17,10 @@ export type ExtraArg = {
export type ModelType = 'llm' | 'embedding' | 'rerank';
export const DEFAULT_REASONING_CONFIG: ReasoningConfig = {
level: 'provider_default',
};
export interface ProviderModels {
llm: LLMModel[];
embedding: EmbeddingModel[];
@@ -53,12 +58,14 @@ export interface ModelItemProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
contextLength?: number | null,
) => Promise<void>;
onTest: (
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
) => Promise<void>;
isSubmitting: boolean;
isTesting: boolean;
@@ -90,6 +97,7 @@ export interface ProviderCardProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
contextLength?: number | null,
) => Promise<void>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
@@ -105,6 +113,7 @@ export interface ProviderCardProps {
name: string,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
contextLength?: number | null,
) => Promise<void>;
onOpenDeleteConfirm: (modelId: string) => void;
@@ -115,6 +124,7 @@ export interface ProviderCardProps {
modelType: ModelType,
abilities: string[],
extraArgs: ExtraArg[],
reasoningConfig: ReasoningConfig,
) => Promise<void>;
isSubmitting: boolean;
isTesting: boolean;
@@ -15,13 +15,15 @@ import {
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
export type QrLoginPlatform =
| 'feishu'
| 'weixin'
| 'dingtalk'
| 'wecombot'
| 'itchat';
| 'itchat'
| 'qqofficial';
interface PlatformConfig {
titleKey: string;
@@ -34,6 +36,7 @@ interface PlatformConfig {
apiBase: string;
extractSuccess: (data: Record<string, string>) => Record<string, string>;
successNoteKey?: string;
boundByKey?: string;
}
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
@@ -54,12 +57,12 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
},
weixin: {
titleKey: 'weixin.scanLogin',
connectingKey: 'feishu.connecting',
connectingKey: 'weixin.connecting',
scanQRCodeKey: 'weixin.scanQRCode',
waitingKey: 'feishu.waitingForScan',
waitingKey: 'weixin.waitingForScan',
successKey: 'weixin.loginSuccess',
failedKey: 'weixin.loginFailed',
retryKey: 'feishu.retry',
retryKey: 'weixin.retry',
apiBase: '/api/v1/platform/adapters/weixin/login',
extractSuccess: (data) => ({
token: data.token,
@@ -111,6 +114,22 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
nickname: data.nickname || '',
}),
},
qqofficial: {
titleKey: 'qqofficial.createBinding',
connectingKey: 'qqofficial.connecting',
scanQRCodeKey: 'qqofficial.scanQRCode',
waitingKey: 'qqofficial.waitingForScan',
successKey: 'qqofficial.bindSuccess',
failedKey: 'qqofficial.bindFailed',
retryKey: 'qqofficial.retry',
apiBase: '/api/v1/platform/adapters/qqofficial/bind',
extractSuccess: (data) => ({
appid: data.appid,
secret: data.secret,
}),
successNoteKey: 'qqofficial.tokenNote',
boundByKey: 'qqofficial.boundBy',
},
};
interface QrCodeLoginDialogProps {
@@ -138,11 +157,14 @@ export default function QrCodeLoginDialog({
const qrDataUrlRef = useRef('');
const [expireIn, setExpireIn] = useState(0);
const [errorMessage, setErrorMessage] = useState('');
const [successMeta, setSuccessMeta] = useState('');
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | null>(null);
const sessionWorkspaceUuidRef = useRef<string | null>(null);
const sessionApiBaseRef = useRef('');
const baseUrlRef = useRef('');
const cleanedRef = useRef(false);
@@ -177,18 +199,23 @@ export default function QrCodeLoginDialog({
}
if (sessionIdRef.current) {
const token = localStorage.getItem('token');
const baseUrl =
import.meta.env.VITE_API_BASE_URL || window.location.origin;
const workspaceUuid = sessionWorkspaceUuidRef.current;
fetch(
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
`${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
keepalive: true,
},
).catch(() => {});
sessionIdRef.current = null;
}
sessionWorkspaceUuidRef.current = null;
sessionApiBaseRef.current = '';
baseUrlRef.current = '';
}, []);
const startLogin = useCallback(async () => {
@@ -199,8 +226,10 @@ export default function QrCodeLoginDialog({
qrDataUrlRef.current = '';
setExpireIn(0);
setErrorMessage('');
setSuccessMeta('');
const token = localStorage.getItem('token');
const workspaceUuid = getActiveWorkspaceUuid();
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
baseUrlRef.current = baseUrl;
const cfg = platformConfigRef.current;
@@ -211,7 +240,10 @@ export default function QrCodeLoginDialog({
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
signal: controller.signal,
});
@@ -222,6 +254,8 @@ export default function QrCodeLoginDialog({
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
sessionIdRef.current = session_id;
sessionWorkspaceUuidRef.current = workspaceUuid;
sessionApiBaseRef.current = cfg.apiBase;
if (qr_data_url) {
setQrDataUrl(qr_data_url);
@@ -269,11 +303,19 @@ export default function QrCodeLoginDialog({
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid
? { 'X-Workspace-Id': workspaceUuid }
: {}),
},
keepalive: true,
},
).catch(() => {});
sessionIdRef.current = null;
sessionWorkspaceUuidRef.current = null;
sessionApiBaseRef.current = '';
baseUrlRef.current = '';
}
setState('expired');
}
@@ -285,7 +327,12 @@ export default function QrCodeLoginDialog({
try {
const pollRes = await fetch(
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
{ headers: { Authorization: `Bearer ${token}` } },
{
headers: {
Authorization: `Bearer ${token}`,
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
},
},
);
if (!pollRes.ok) return;
@@ -298,6 +345,13 @@ export default function QrCodeLoginDialog({
sessionIdRef.current = null;
cleanup();
setState('success');
// Platform may return extra audit metadata (e.g. QQ Official returns
// the scanner's user_openid) — surface it briefly before the dialog closes.
if (rest.user_openid && cfg.boundByKey) {
setSuccessMeta(
tRef.current(cfg.boundByKey, { openid: rest.user_openid }),
);
}
setTimeout(() => {
onSuccessRef.current(cfg.extractSuccess(rest));
onOpenChangeRef.current(false);
@@ -431,6 +485,11 @@ export default function QrCodeLoginDialog({
<p className="text-sm text-green-600 font-medium">
{t(platformConfig.successKey)}
</p>
{successMeta && (
<p className="text-xs text-muted-foreground text-center max-w-xs break-all">
{successMeta}
</p>
)}
{platformConfig.successNoteKey && (
<p className="text-xs text-muted-foreground text-center max-w-xs">
{t(platformConfig.successNoteKey)}
@@ -0,0 +1,97 @@
import { BrainCircuit, ChevronDown, ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ReasoningLevel } from '@/app/infra/entities/api';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Slider } from '@/components/ui/slider';
export const REASONING_LEVELS: ReasoningLevel[] = [
'provider_default',
'disabled',
'enabled',
'minimal',
'low',
'medium',
'high',
'xhigh',
'max',
];
export const REASONING_LEVEL_LABEL_KEYS: Record<ReasoningLevel, string> = {
provider_default: 'models.reasoningLevels.providerDefault',
disabled: 'models.reasoningLevels.disabled',
enabled: 'models.reasoningLevels.enabled',
minimal: 'models.reasoningLevels.minimal',
low: 'models.reasoningLevels.low',
medium: 'models.reasoningLevels.medium',
high: 'models.reasoningLevels.high',
xhigh: 'models.reasoningLevels.xhigh',
max: 'models.reasoningLevels.max',
};
interface ReasoningLevelPickerProps {
value: ReasoningLevel;
levels: ReasoningLevel[];
disabled?: boolean;
onChange: (value: ReasoningLevel) => void;
}
export default function ReasoningLevelPicker({
value,
levels,
disabled = false,
onChange,
}: ReasoningLevelPickerProps) {
const { t } = useTranslation();
const safeLevels: ReasoningLevel[] =
levels.length > 0 ? levels : ['provider_default'];
const safeValue: ReasoningLevel = safeLevels.includes(value)
? value
: safeLevels[0];
const currentLabel = t(REASONING_LEVEL_LABEL_KEYS[safeValue]);
const isExplicit = safeValue !== 'provider_default';
const currentIndex = Math.max(0, safeLevels.indexOf(safeValue));
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || safeLevels.length <= 1}
aria-label={`${t('models.reasoningLevel')}: ${currentLabel}`}
className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-36"
>
<BrainCircuit
className={`size-4 shrink-0 ${isExplicit ? 'text-primary' : 'text-muted-foreground'}`}
/>
<span className="hidden min-w-0 truncate sm:block">
{currentLabel}
</span>
<ChevronDown className="hidden size-3.5 shrink-0 text-muted-foreground sm:block" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[272px] p-4">
<div className="flex h-5 items-center gap-0.5 text-sm text-muted-foreground">
<span>{currentLabel}</span>
<ChevronRight className="size-3.5" />
</div>
<Slider
className="mt-5"
min={0}
max={Math.max(0, safeLevels.length - 1)}
step={1}
value={[currentIndex]}
aria-label={t('models.reasoningLevel')}
aria-valuetext={currentLabel}
onValueChange={([index]) => onChange(safeLevels[index])}
/>
</PopoverContent>
</Popover>
);
}
@@ -1,6 +1,12 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
import {
HardDrive,
KeyRound,
Settings,
Sparkles,
UsersRound,
} from 'lucide-react';
import {
Dialog,
DialogContent,
@@ -22,10 +28,13 @@ import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/
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';
import WorkspaceSettingsPanel from '@/app/home/components/workspace-settings/WorkspaceSettingsPanel';
import { useCurrentWorkspace } from '@/app/infra/http';
// 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 =
| 'workspace'
| 'account'
| 'apiIntegration'
| 'models'
@@ -35,6 +44,7 @@ export type SettingsSection =
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
// showStorageAnalysis) continue to resolve to the right section.
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
workspace: 'showWorkspaceSettings',
account: 'showAccountSettings',
apiIntegration: 'showApiIntegrationSettings',
models: 'showModelSettings',
@@ -63,6 +73,7 @@ export default function SettingsDialog({
onSectionChange,
}: SettingsDialogProps) {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
// A nested modal (e.g. the provider form) can request that we ignore
// outer-close until it is dismissed.
const [blocking, setBlocking] = useState(false);
@@ -76,13 +87,20 @@ export default function SettingsDialog({
}
}, [section, open]);
const navItems: {
const allNavItems: {
id: SettingsSection;
label: string;
title: string;
description: string;
icon: React.ReactNode;
}[] = [
{
id: 'workspace',
label: t('settingsDialog.nav.workspace'),
title: t('workspace.title'),
description: t('workspace.description'),
icon: <UsersRound className="size-4" />,
},
{
id: 'models',
label: t('settingsDialog.nav.models'),
@@ -112,6 +130,35 @@ export default function SettingsDialog({
icon: <Settings className="size-4" />,
},
];
const permissions = currentWorkspace?.permissions ?? [];
const canManageApiKeys = permissions.includes('api_key.manage');
const canViewAudit = permissions.includes('audit.view');
const canViewStorageAnalysis =
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
const navItems = allNavItems.filter((item) => {
if (item.id === 'apiIntegration') {
return canManageApiKeys;
}
if (item.id === 'storageAnalysis') {
return canViewStorageAnalysis;
}
return true;
});
useEffect(() => {
const forbiddenSection =
(section === 'apiIntegration' && !canManageApiKeys) ||
(section === 'storageAnalysis' && !canViewStorageAnalysis);
if (open && forbiddenSection) {
onSectionChange('workspace');
}
}, [
canManageApiKeys,
canViewStorageAnalysis,
open,
section,
onSectionChange,
]);
const activeItem = navItems.find((item) => item.id === section);
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
@@ -201,6 +248,11 @@ export default function SettingsDialog({
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{section === 'workspace' && (
<WorkspaceSettingsPanel
active={open && section === 'workspace'}
/>
)}
{section === 'models' && (
<ModelsPanel
active={open && section === 'models'}
@@ -212,7 +264,7 @@ export default function SettingsDialog({
active={open && section === 'apiIntegration'}
/>
)}
{section === 'storageAnalysis' && (
{section === 'storageAnalysis' && canViewStorageAnalysis && (
<StorageAnalysisPanel
active={open && section === 'storageAnalysis'}
/>
@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus';
export function WorkspaceQuotaTooltip({
quota,
resource,
children,
side = 'top',
}: {
quota: WorkspaceQuotaItem;
resource: string;
children: ReactNode;
side?: 'top' | 'right' | 'bottom' | 'left';
}) {
const { t } = useTranslation();
if (!quota.disabled) return children;
const message = quota.loading
? t('limitation.quotaLoadingTooltip')
: t('limitation.createDisabledTooltip', {
resource,
max: quota.max,
});
return (
<Tooltip>
<TooltipTrigger asChild>
<span
tabIndex={0}
aria-disabled="true"
aria-label={message}
className="inline-flex cursor-not-allowed rounded-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{children}
</span>
</TooltipTrigger>
<TooltipContent side={side} className="max-w-72 text-left">
{message}
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,82 @@
import { systemInfo } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
export interface WorkspaceQuotaItem {
count: number;
max: number;
reached: boolean;
loading: boolean;
disabled: boolean;
}
export interface WorkspaceQuotaStatus {
bots: WorkspaceQuotaItem;
pipelines: WorkspaceQuotaItem;
knowledgeBases: WorkspaceQuotaItem;
extensions: WorkspaceQuotaItem;
botsReached: boolean;
pipelinesReached: boolean;
knowledgeBasesReached: boolean;
extensionsReached: boolean;
}
function quotaItem(
count: number,
max: number | undefined,
loaded: boolean,
): WorkspaceQuotaItem {
const normalizedMax = typeof max === 'number' ? max : -1;
const reached = loaded && normalizedMax >= 0 && count >= normalizedMax;
return {
count,
max: normalizedMax,
reached,
loading: !loaded,
disabled: !loaded || reached,
};
}
export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
const {
bots,
pipelines,
knowledgeBases,
pluginCount,
mcpServers,
skills,
quotaDataLoaded,
} = useSidebarData();
const limitation = systemInfo.limitation;
const botQuota = quotaItem(
bots.length,
limitation?.max_bots,
quotaDataLoaded,
);
const pipelineQuota = quotaItem(
pipelines.length,
limitation?.max_pipelines,
quotaDataLoaded,
);
const knowledgeBaseQuota = quotaItem(
knowledgeBases.length,
limitation?.max_knowledge_bases,
quotaDataLoaded,
);
const extensionQuota = quotaItem(
pluginCount + mcpServers.length + skills.length,
limitation?.max_extensions,
quotaDataLoaded,
);
return {
bots: botQuota,
pipelines: pipelineQuota,
knowledgeBases: knowledgeBaseQuota,
extensions: extensionQuota,
botsReached: botQuota.disabled,
pipelinesReached: pipelineQuota.disabled,
knowledgeBasesReached: knowledgeBaseQuota.disabled,
extensionsReached: extensionQuota.disabled,
};
}
@@ -0,0 +1,409 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Copy,
ExternalLink,
Loader2,
Trash2,
UserPlus,
Users,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@/components/ui/item';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type {
CurrentWorkspace,
WorkspaceInvitation,
WorkspaceMembership,
WorkspaceRole,
} from '@/app/infra/entities/workspace';
import { backendClient, systemInfo } from '@/app/infra/http';
import {
PanelBody,
PanelToolbar,
} from '@/app/home/components/settings-dialog/panel-layout';
interface WorkspaceSettingsPanelProps {
active: boolean;
}
const ASSIGNABLE_ROLES: Exclude<WorkspaceRole, 'owner'>[] = [
'admin',
'developer',
'operator',
'viewer',
];
export default function WorkspaceSettingsPanel({
active,
}: WorkspaceSettingsPanelProps) {
const { t } = useTranslation();
const [workspaceInfo, setWorkspaceInfo] = useState<CurrentWorkspace | null>(
null,
);
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
const [loading, setLoading] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] =
useState<Exclude<WorkspaceRole, 'owner'>>('viewer');
const [inviteLoading, setInviteLoading] = useState(false);
const [oneTimeInviteLink, setOneTimeInviteLink] = useState<string | null>(
null,
);
const permissions = useMemo(
() => new Set(workspaceInfo?.permissions ?? []),
[workspaceInfo],
);
const isCloudProjection =
workspaceInfo?.workspace.source === 'cloud_projection';
const canViewMembers = permissions.has('member.view');
const canInvite = permissions.has('member.invite');
const canUpdateMembers = permissions.has('member.update_role');
const canRemoveMembers = permissions.has('member.remove');
const cloudPortalURL = workspaceInfo
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
: '';
const loadWorkspace = useCallback(async () => {
setLoading(true);
try {
const current = await backendClient.getCurrentWorkspace();
setWorkspaceInfo(current);
const [memberResponse, invitationResponse] = await Promise.all([
current.permissions.includes('member.view')
? backendClient.getWorkspaceMembers(current.workspace.uuid)
: Promise.resolve({ members: [] }),
current.permissions.includes('member.invite')
? backendClient.getWorkspaceInvitations(current.workspace.uuid)
: Promise.resolve({ invitations: [] }),
]);
setMembers(memberResponse.members);
setInvitations(invitationResponse.invitations);
} catch {
toast.error(t('workspace.loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
if (active) void loadWorkspace();
}, [active, loadWorkspace]);
async function createInvitation() {
if (!workspaceInfo || !inviteEmail.trim()) return;
setInviteLoading(true);
try {
const response = await backendClient.createWorkspaceInvitation(
workspaceInfo.workspace.uuid,
inviteEmail.trim(),
inviteRole,
);
setOneTimeInviteLink(response.link);
setInviteEmail('');
await loadWorkspace();
toast.success(t(`workspace.delivery.${response.delivery.status}`));
} catch {
toast.error(t('workspace.invitationCreateFailed'));
} finally {
setInviteLoading(false);
}
}
async function copyInvitationLink() {
if (!oneTimeInviteLink) return;
await navigator.clipboard.writeText(oneTimeInviteLink);
toast.success(t('workspace.invitationCopied'));
}
async function updateMemberRole(
member: WorkspaceMembership,
role: WorkspaceRole,
) {
if (!workspaceInfo || member.role === role) return;
try {
await backendClient.updateWorkspaceMemberRole(
workspaceInfo.workspace.uuid,
member.account_uuid,
role,
);
await loadWorkspace();
toast.success(t('workspace.memberUpdated'));
} catch {
toast.error(t('workspace.memberUpdateFailed'));
}
}
async function removeMember(member: WorkspaceMembership) {
if (!workspaceInfo) return;
if (!window.confirm(t('workspace.removeMemberConfirm'))) return;
try {
await backendClient.removeWorkspaceMember(
workspaceInfo.workspace.uuid,
member.account_uuid,
);
await loadWorkspace();
toast.success(t('workspace.memberRemoved'));
} catch {
toast.error(t('workspace.memberRemoveFailed'));
}
}
async function revokeInvitation(invitation: WorkspaceInvitation) {
if (!workspaceInfo) return;
try {
await backendClient.revokeWorkspaceInvitation(
workspaceInfo.workspace.uuid,
invitation.uuid,
);
await loadWorkspace();
toast.success(t('workspace.invitationRevoked'));
} catch {
toast.error(t('workspace.invitationRevokeFailed'));
}
}
if (loading && !workspaceInfo) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<>
<PanelToolbar>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{workspaceInfo?.workspace.name ?? t('workspace.title')}
</p>
<p className="text-xs text-muted-foreground">
{t(
isCloudProjection
? 'workspace.cloudManagedDescription'
: 'workspace.ossSingletonDescription',
)}
</p>
</div>
{workspaceInfo && (
<Badge variant="secondary">
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
</Badge>
)}
{isCloudProjection && workspaceInfo && (
<Button asChild size="sm">
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
{t('workspace.upgradePlan')}
<ExternalLink className="size-3.5" />
</a>
</Button>
)}
</PanelToolbar>
<PanelBody className="space-y-6">
{canInvite && (
<section className="space-y-3">
<div>
<h3 className="text-sm font-semibold">
{t('workspace.inviteMember')}
</h3>
<p className="text-xs text-muted-foreground">
{t('workspace.inviteDescription')}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
type="email"
value={inviteEmail}
onChange={(event) => setInviteEmail(event.target.value)}
placeholder={t('workspace.emailPlaceholder')}
className="flex-1"
/>
<Select
value={inviteRole}
onValueChange={(value) =>
setInviteRole(value as Exclude<WorkspaceRole, 'owner'>)
}
>
<SelectTrigger className="w-full sm:w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={createInvitation}
disabled={inviteLoading || !inviteEmail.trim()}
>
{inviteLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserPlus className="size-4" />
)}
{t('workspace.createInvitation')}
</Button>
</div>
{oneTimeInviteLink && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
<p className="mb-2 text-xs text-muted-foreground">
{t('workspace.oneTimeLinkWarning')}
</p>
<div className="flex gap-2">
<Input value={oneTimeInviteLink} readOnly />
<Button
variant="outline"
size="icon"
onClick={copyInvitationLink}
aria-label={t('workspace.copyInvitation')}
>
<Copy className="size-4" />
</Button>
</div>
</div>
)}
</section>
)}
{canViewMembers && (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">
{t('workspace.members')}
</h3>
</div>
<div className="space-y-2">
{members.map((member) => {
const isSelf =
member.account_uuid ===
workspaceInfo?.membership.account_uuid;
return (
<Item
key={member.uuid}
size="sm"
variant="muted"
className="rounded-lg"
>
<ItemMedia variant="icon">
<Users className="size-4" />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle>
{member.display_name}
{isSelf && (
<Badge variant="outline">{t('workspace.you')}</Badge>
)}
</ItemTitle>
<ItemDescription className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<span className="break-all">{member.email}</span>
<span aria-hidden="true">·</span>
<span>{t(`workspace.roles.${member.role}`)}</span>
</ItemDescription>
</ItemContent>
<ItemActions className="max-sm:basis-full max-sm:justify-end max-sm:pl-10">
{canUpdateMembers && member.role !== 'owner' && (
<Select
value={member.role}
onValueChange={(role) =>
void updateMemberRole(member, role as WorkspaceRole)
}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASSIGNABLE_ROLES.map((role) => (
<SelectItem key={role} value={role}>
{t(`workspace.roles.${role}`)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{canRemoveMembers &&
!isSelf &&
member.role !== 'owner' && (
<Button
variant="ghost"
size="icon"
onClick={() => void removeMember(member)}
aria-label={t('workspace.removeMember')}
>
<Trash2 className="size-4" />
</Button>
)}
</ItemActions>
</Item>
);
})}
</div>
</section>
)}
{canInvite && invitations.length > 0 && (
<section className="space-y-3">
<h3 className="text-sm font-semibold">
{t('workspace.pendingInvitations')}
</h3>
<div className="space-y-2">
{invitations.map((invitation) => (
<Item
key={invitation.uuid}
size="sm"
variant="outline"
className="rounded-lg"
>
<ItemContent>
<ItemTitle>{invitation.normalized_email}</ItemTitle>
<ItemDescription>
{t(`workspace.roles.${invitation.role}`)} ·{' '}
{t('workspace.expiresAt', {
date: new Date(invitation.expires_at).toLocaleString(),
})}
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
variant="ghost"
size="icon"
onClick={() => void revokeInvitation(invitation)}
aria-label={t('workspace.revokeInvitation')}
>
<Trash2 className="size-4" />
</Button>
</ItemActions>
</Item>
))}
</div>
</section>
)}
</PanelBody>
</>
);
}
@@ -0,0 +1,96 @@
import { Building2, Check, Settings } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
switchWorkspaceAndReload,
useCurrentWorkspace,
useWorkspaceBootstrap,
} from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
export const OPEN_WORKSPACE_SETTINGS_EVENT = 'langbot:open-workspace-settings';
export function requestWorkspaceSettings(): void {
window.dispatchEvent(new Event(OPEN_WORKSPACE_SETTINGS_EVENT));
}
export default function WorkspaceSwitcher({
className,
}: {
className?: string;
}) {
const { t } = useTranslation();
const currentWorkspace = useCurrentWorkspace();
const workspaces = useWorkspaceBootstrap();
if (!currentWorkspace) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('h-9 min-w-0 justify-start px-2.5 text-sm', className)}
aria-label={t('workspace.switchWorkspace')}
>
<Building2 className="size-4 shrink-0" />
<span className="truncate">{currentWorkspace.workspace.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 p-1.5">
<DropdownMenuLabel className="px-3 py-2 text-sm">
{t('workspace.switchWorkspace')}
</DropdownMenuLabel>
{workspaces.map((entry) => {
const selected =
entry.workspace.uuid === currentWorkspace.workspace.uuid;
return (
<DropdownMenuItem
key={entry.workspace.uuid}
className="min-h-11 gap-2 px-2 py-1.5"
onClick={() => {
if (!selected)
void switchWorkspaceAndReload(entry.workspace.uuid);
}}
>
<Building2 className="size-4 shrink-0" />
<span className="max-w-[7rem] min-w-0 flex-1 truncate font-medium">
{entry.workspace.name}
</span>
{entry.workspace.source === 'cloud_projection' && (
<span className="rounded-md border bg-muted px-2 py-0.5 text-[11px] font-medium uppercase text-muted-foreground">
{entry.plan_name || t('workspace.planUnavailable')}
</span>
)}
{selected && <Check className="size-4" />}
{selected && (
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={t('workspace.settings')}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
requestWorkspaceSettings();
}}
>
<Settings className="size-4" />
</Button>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}