feat(mcp): support mcp resources (#2215)

* feat(mcp): support mcp resources

* feat(web): split MCP resources into tab

* docs: add MCP resources PR review

* feat(mcp): productionize resource support

* feat(mcp): scope local agent tools and resources

* fix(web): gate space embedding models behind login

* fix(web): prevent clipped space model CTA

* test: update preproc resource tool expectations

* fix(web): expose skill authoring tools in selector

---------

Co-authored-by: yang.xiang <yang.xiang@advancegroup.com>
Co-authored-by: Hyu <chenhyu@proton.me>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
advancer-young
2026-06-30 19:16:30 +08:00
committed by GitHub
parent 2618e06492
commit 096ec1a8ce
39 changed files with 4103 additions and 201 deletions
@@ -64,15 +64,23 @@ import {
import SettingsDialog, {
SettingsSection,
} 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';
export default function DynamicFormItemComponent({
config,
field,
formValues,
onFileUploaded,
setFormValue,
systemContext,
}: {
config: IDynamicFormItemSchema;
field: ControllerRenderProps<any, any>;
formValues?: Record<string, unknown>;
onFileUploaded?: (fileKey: string) => void;
setFormValue?: (name: string, value: unknown) => void;
systemContext?: Record<string, unknown>;
}) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
@@ -103,10 +111,34 @@ export default function DynamicFormItemComponent({
});
};
const fetchEmbeddingModels = () => {
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
setEmbeddingModels(resp.models);
})
.catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg);
});
};
const fetchRerankModels = () => {
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
});
};
const handleModelsDialogChange = (open: boolean) => {
setModelsDialogOpen(open);
if (!open) {
fetchLlmModels();
fetchEmbeddingModels();
fetchRerankModels();
}
};
@@ -174,27 +206,13 @@ export default function DynamicFormItemComponent({
useEffect(() => {
if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
setEmbeddingModels(resp.models);
})
.catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg);
});
fetchEmbeddingModels();
}
}, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) {
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
});
fetchRerankModels();
}
}, [config.type]);
@@ -248,6 +266,16 @@ export default function DynamicFormItemComponent({
}
}, [config.type]);
const handleCompositePatch = (patch: Record<string, unknown>) => {
for (const [name, value] of Object.entries(patch)) {
if (setFormValue) {
setFormValue(name, value);
} else if (name === field.name) {
field.onChange(value);
}
}
};
switch (config.type) {
case DynamicFormItemType.INT:
case DynamicFormItemType.FLOAT:
@@ -377,10 +405,10 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Separate space models from regular models
const spaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const regularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
// Group regular models by provider
@@ -481,7 +509,7 @@ export default function DynamicFormItemComponent({
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div className="relative min-h-10">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
@@ -558,7 +586,10 @@ export default function DynamicFormItemComponent({
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => setModelsDialogOpen(true)}
onClick={() => {
setSettingsSection('models');
setModelsDialogOpen(true);
}}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
@@ -574,9 +605,15 @@ export default function DynamicFormItemComponent({
</div>
);
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
// Group embedding models by provider
const groupedEmbeddingModels = embeddingModels.reduce(
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: {
const spaceEmbeddingModels = embeddingModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const regularEmbeddingModels = embeddingModels.filter(
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const groupedEmbeddingModels = regularEmbeddingModels.reduce(
(acc, model) => {
const providerName = model.provider?.name || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
@@ -586,29 +623,169 @@ export default function DynamicFormItemComponent({
{} as Record<string, EmbeddingModel[]>,
);
const groupedSpaceEmbeddingModels = spaceEmbeddingModels.reduce(
(acc, model) => {
const providerName =
model.provider?.name || model.provider?.requester || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, EmbeddingModel[]>,
);
const previewEmbeddingModelNames = [
'text-embedding-3-large',
'text-embedding-3-small',
'bge-m3',
'jina-embeddings-v3',
'qwen3-embedding-8b',
];
return (
<div className="w-full max-w-md min-w-0">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue
placeholder={t('knowledge.selectEmbeddingModel')}
/>
</SelectTrigger>
<SelectContent>
{Object.entries(groupedEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)}
{showSpaceLoginCTA ? (
<SelectGroup>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{t('models.langbotModels')}
<Tooltip>
<TooltipTrigger
asChild
onMouseDown={(e) => e.preventDefault()}
>
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{t('models.spaceTrialTooltip')}
</TooltipContent>
</Tooltip>
</span>
</SelectLabel>
<div
className="relative"
onMouseDown={(e) => e.preventDefault()}
>
{(spaceEmbeddingModels.length > 0
? spaceEmbeddingModels.map((m) => m.name)
: previewEmbeddingModelNames
)
.slice(0, 3)
.map((name) => (
<div
key={name}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
>
{name}
</div>
))}
<div className="relative min-h-10">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
>
{(spaceEmbeddingModels.length > 0
? spaceEmbeddingModels.map((m) => m.name)
: previewEmbeddingModelNames
)
.slice(3)
.map((name) => (
<div
key={name}
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
>
{name}
</div>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleSpaceLogin();
}}
>
<Sparkles className="h-3 w-3" />
{t('models.unlockModels')}
</Button>
</div>
</div>
</div>
</SelectGroup>
),
)}
</SelectContent>
</Select>
) : !systemInfo.disable_models_service ? (
Object.entries(groupedSpaceEmbeddingModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>
<span className="inline-flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
{providerName}
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)
) : null}
</SelectContent>
</Select>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
setSettingsSection('models');
setModelsDialogOpen(true);
}}
>
<Settings className="h-4 w-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">{t('models.title')}</TooltipContent>
</Tooltip>
<SettingsDialog
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
section={settingsSection}
onSectionChange={setSettingsSection}
/>
</div>
);
}
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
const groupedRerankModels = rerankModels.reduce(
@@ -652,10 +829,10 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Separate space models from regular models
const fbSpaceModels = llmModels.filter(
(m) => m.provider?.requester === 'space-chat-completions',
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
);
const fbRegularModels = llmModels.filter(
(m) => m.provider?.requester !== 'space-chat-completions',
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
// Group regular models by provider
@@ -786,7 +963,7 @@ export default function DynamicFormItemComponent({
</div>
))}
{/* Blurred remaining models with login overlay */}
<div className="relative">
<div className="relative min-h-10">
<div
className="select-none overflow-hidden"
style={{ maxHeight: '3rem' }}
@@ -1383,6 +1560,32 @@ export default function DynamicFormItemComponent({
</>
);
case DynamicFormItemType.RICH_TOOLS_SELECTOR:
return (
<ToolResourceSelectors
mode="tools"
pipelineId={systemContext?.pipeline_id as string | undefined}
value={{
...(formValues || {}),
[field.name]: field.value,
}}
onChange={handleCompositePatch}
/>
);
case DynamicFormItemType.RESOURCES_SELECTOR:
return (
<ToolResourceSelectors
mode="resources"
pipelineId={systemContext?.pipeline_id as string | undefined}
value={{
...(formValues || {}),
[field.name]: field.value,
}}
onChange={handleCompositePatch}
/>
);
case DynamicFormItemType.PROMPT_EDITOR: {
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default