mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-21 16:05:19 +08:00
fix(web): load installed plugin icons through the authenticated route
Multi-Workspace deployments fail the public plugin icon route closed (404), so plain <img src> icons broke everywhere locally and for the runner picker in production. A shared cached hook fetches the authenticated icon for installed plugins and leaves marketplace surfaces on the cloud URL.
This commit is contained in:
@@ -2,7 +2,7 @@ import { Puzzle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { RunnerDescriptor } from '@/app/infra/entities/api';
|
||||
import { httpClient } from '@/app/infra/http';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import {
|
||||
Select,
|
||||
@@ -27,6 +27,10 @@ function ProcessorComponentContent({
|
||||
...component.label,
|
||||
});
|
||||
const pluginId = `${component.plugin_author}/${component.plugin_name}`;
|
||||
const iconURL = useInstalledPluginIcon(
|
||||
component.plugin_author,
|
||||
component.plugin_name,
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
@@ -35,18 +39,25 @@ function ProcessorComponentContent({
|
||||
: 'flex min-w-0 items-center gap-2'
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
component.plugin_author,
|
||||
component.plugin_name,
|
||||
)}
|
||||
alt=""
|
||||
className={
|
||||
option
|
||||
? 'row-span-2 size-7 shrink-0 rounded-md object-cover'
|
||||
: 'size-5 shrink-0 rounded object-cover'
|
||||
}
|
||||
/>
|
||||
{iconURL ? (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt=""
|
||||
className={
|
||||
option
|
||||
? 'row-span-2 size-7 shrink-0 rounded-md object-cover'
|
||||
: 'size-5 shrink-0 rounded object-cover'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Puzzle
|
||||
className={
|
||||
option
|
||||
? 'row-span-2 size-5 shrink-0 justify-self-center text-muted-foreground'
|
||||
: 'size-4 shrink-0 text-muted-foreground'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<span className={option ? 'truncate font-medium leading-5' : 'truncate'}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Bot, ExternalLink, Loader2, Store } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { getCloudServiceClientSync, httpClient } from '@/app/infra/http';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
|
||||
import type { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import {
|
||||
@@ -51,13 +52,13 @@ function installErrorMessage(
|
||||
return getErrorMessage(error) || t('wizard.aiEngine.installFailed');
|
||||
}
|
||||
|
||||
function installedRunnerIconURL(option: IDynamicFormItemOption) {
|
||||
return option.name.startsWith('plugin:')
|
||||
? (() => {
|
||||
const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
|
||||
return match ? httpClient.getPluginIconURL(match[1], match[2]) : null;
|
||||
})()
|
||||
: null;
|
||||
function pluginOptionParts(option: IDynamicFormItemOption): {
|
||||
author: string;
|
||||
name: string;
|
||||
} | null {
|
||||
if (!option.name.startsWith('plugin:')) return null;
|
||||
const match = option.name.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
|
||||
return match ? { author: match[1], name: match[2] } : null;
|
||||
}
|
||||
|
||||
function InstalledRunnerContent({
|
||||
@@ -65,7 +66,8 @@ function InstalledRunnerContent({
|
||||
}: {
|
||||
option: IDynamicFormItemOption;
|
||||
}) {
|
||||
const iconURL = installedRunnerIconURL(option);
|
||||
const parts = pluginOptionParts(option);
|
||||
const iconURL = useInstalledPluginIcon(parts?.author, parts?.name);
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
@@ -90,7 +92,8 @@ function InstalledRunnerOptionContent({
|
||||
option: IDynamicFormItemOption;
|
||||
description: string;
|
||||
}) {
|
||||
const iconURL = installedRunnerIconURL(option);
|
||||
const parts = pluginOptionParts(option);
|
||||
const iconURL = useInstalledPluginIcon(parts?.author, parts?.name);
|
||||
|
||||
return (
|
||||
<span className="grid w-full min-w-0 grid-cols-[1.75rem_minmax(0,1fr)] items-center gap-x-2 text-left">
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ControllerRenderProps } from 'react-hook-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient, systemInfo, userInfo } from '@/app/infra/http';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import {
|
||||
LLMModel,
|
||||
Bot,
|
||||
@@ -94,19 +95,6 @@ function hasUsableOptionName(option: { name?: string | null }): boolean {
|
||||
return typeof option.name === 'string' && option.name.trim().length > 0;
|
||||
}
|
||||
|
||||
function getPluginComponentIconURL(value?: string): string | null {
|
||||
if (!value?.startsWith('plugin:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = value.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return httpClient.getPluginIconURL(match[1], match[2]);
|
||||
}
|
||||
|
||||
function SelectOptionContent({
|
||||
label,
|
||||
value,
|
||||
@@ -114,7 +102,10 @@ function SelectOptionContent({
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
const iconURL = getPluginComponentIconURL(value);
|
||||
const match = value?.startsWith('plugin:')
|
||||
? value.match(/^plugin:([^/]+)\/([^/]+)(?:\/|$)/)
|
||||
: null;
|
||||
const iconURL = useInstalledPluginIcon(match?.[1], match?.[2]);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { CircleHelp, Plus, X, Server, Wrench, Sparkles } from 'lucide-react';
|
||||
import { CircleHelp, Plus, X, Server, Wrench, Sparkles, Puzzle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -26,6 +26,27 @@ import { MCPServer, Skill } from '@/app/infra/entities/api';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
|
||||
function PluginThumbnail({ author, name }: { author: string; name: string }) {
|
||||
const iconURL = useInstalledPluginIcon(author, name);
|
||||
|
||||
if (!iconURL) {
|
||||
return (
|
||||
<div className="w-10 h-10 rounded-lg border bg-muted flex items-center justify-center flex-shrink-0">
|
||||
<Puzzle className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt={name}
|
||||
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTooltip({ label }: { label: string }) {
|
||||
return (
|
||||
@@ -373,13 +394,9 @@ export default function PipelineExtension({
|
||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<img
|
||||
src={backendClient.getPluginIconURL(
|
||||
metadata.author || '',
|
||||
metadata.name,
|
||||
)}
|
||||
alt={metadata.name}
|
||||
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
||||
<PluginThumbnail
|
||||
author={metadata.author || ''}
|
||||
name={metadata.name}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{metadata.name}</div>
|
||||
@@ -665,13 +682,9 @@ export default function PipelineExtension({
|
||||
onClick={() => handleTogglePlugin(pluginId)}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
<img
|
||||
src={backendClient.getPluginIconURL(
|
||||
metadata.author || '',
|
||||
metadata.name,
|
||||
)}
|
||||
alt={metadata.name}
|
||||
className="w-10 h-10 rounded-lg border bg-muted object-cover flex-shrink-0"
|
||||
<PluginThumbnail
|
||||
author={metadata.author || ''}
|
||||
name={metadata.name}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{metadata.name}</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -39,10 +39,9 @@ export default function ExtensionCardComponent({
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
const authenticatedIcon = useAuthenticatedPluginIcon(
|
||||
cardVO.author,
|
||||
cardVO.name,
|
||||
cardVO.type === 'plugin',
|
||||
const pluginIconURL = useInstalledPluginIcon(
|
||||
cardVO.type === 'plugin' ? cardVO.author : null,
|
||||
cardVO.type === 'plugin' ? cardVO.name : null,
|
||||
);
|
||||
|
||||
const FallbackIcon =
|
||||
@@ -51,9 +50,8 @@ export default function ExtensionCardComponent({
|
||||
: cardVO.type === 'skill'
|
||||
? Sparkles
|
||||
: Puzzle;
|
||||
const iconSrc =
|
||||
cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL;
|
||||
const showFallback = iconFailed || authenticatedIcon.error || !iconSrc;
|
||||
const iconSrc = cardVO.type === 'plugin' ? pluginIconURL : cardVO.iconURL;
|
||||
const showFallback = iconFailed || !iconSrc;
|
||||
|
||||
const getTypeLabel = (type: ExtensionType) => {
|
||||
switch (type) {
|
||||
|
||||
@@ -157,7 +157,9 @@ const PluginInstalledComponent = forwardRef<
|
||||
version: meta.version ?? '',
|
||||
enabled: plugin.enabled,
|
||||
type: marketplacePlugin?.type || 'plugin',
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
// Icon is resolved by ExtensionCardComponent through the shared
|
||||
// installed-plugin hook (authenticated fetch in multi-Workspace
|
||||
// deployments); the public /icon route 404s there.
|
||||
install_source: plugin.install_source,
|
||||
install_info: plugin.install_info,
|
||||
status: plugin.status,
|
||||
|
||||
+21
-7
@@ -2,9 +2,16 @@ import { PluginCardVO } from '@/app/home/plugins/components/plugin-installed/Plu
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BugIcon, ExternalLink, Ellipsis, Trash, ArrowUp } from 'lucide-react';
|
||||
import {
|
||||
BugIcon,
|
||||
ExternalLink,
|
||||
Ellipsis,
|
||||
Trash,
|
||||
ArrowUp,
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -27,6 +34,7 @@ export default function PluginCardComponent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const iconURL = useInstalledPluginIcon(cardVO.author, cardVO.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -36,11 +44,17 @@ export default function PluginCardComponent({
|
||||
>
|
||||
<div className="w-full h-full flex flex-row items-start justify-start gap-[1.2rem]">
|
||||
{/* Icon - fixed width */}
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(cardVO.author, cardVO.name)}
|
||||
alt="plugin icon"
|
||||
className="w-16 h-16 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
{iconURL ? (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt="plugin icon"
|
||||
className="w-16 h-16 rounded-[8%] flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 flex-shrink-0 flex items-center justify-center">
|
||||
<Puzzle className="w-12 h-12 text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area - flexible width with min-width to prevent overflow */}
|
||||
<div className="flex-1 min-w-0 h-full flex flex-col items-start justify-between gap-[0.6rem]">
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { httpClient } from '@/app/infra/http';
|
||||
|
||||
/**
|
||||
* Resolve the icon of an *installed* plugin in every deployment.
|
||||
*
|
||||
* The Core's public `/icon` route only exists in single-workspace (OSS)
|
||||
* installs; in multi-Workspace deployments it 404s, so installed-plugin
|
||||
* surfaces must go through the authenticated `/authenticated-icon` route and
|
||||
* stream the bytes as a blob object URL.
|
||||
*
|
||||
* - Core backend client: blob-fetch `getAuthenticatedPluginIconURL` and cache
|
||||
* the resulting object URL per `author/name` (module-level, shared across
|
||||
* every mounted consumer of the same icon).
|
||||
* - Cloud/Space marketplace surfaces: callers that already resolved a
|
||||
* marketplace URL pass it as `marketplaceURL`; it is returned verbatim and
|
||||
* no blob fetch happens.
|
||||
* - Returns `null` while loading / when the plugin has no icon, so callers
|
||||
* keep rendering their existing fallback.
|
||||
*
|
||||
* Object URLs are revoked once the last subscriber unmounts (or the last
|
||||
* subscriber's fetch settles with no remaining subscriber).
|
||||
*/
|
||||
|
||||
type CachedIcon = { url: string };
|
||||
|
||||
// Resolved object URLs, keyed by `author/name`.
|
||||
const iconCache = new Map<string, CachedIcon>();
|
||||
// In-flight authenticated fetches, de-duplicated per key.
|
||||
const inflightIcons = new Map<string, Promise<string | null>>();
|
||||
// Mounted subscriber count per key, so we only revoke when nobody needs it.
|
||||
const iconRefCounts = new Map<string, number>();
|
||||
|
||||
function loadIcon(
|
||||
key: string,
|
||||
author: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const existing = inflightIcons.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const request = httpClient
|
||||
.getAuthenticatedPluginIconURL(author, name)
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
inflightIcons.delete(key);
|
||||
});
|
||||
inflightIcons.set(key, request);
|
||||
return request;
|
||||
}
|
||||
|
||||
export function useInstalledPluginIcon(
|
||||
author?: string | null,
|
||||
name?: string | null,
|
||||
marketplaceURL?: string | null,
|
||||
): string | null {
|
||||
const key = author && name ? `${author}/${name}` : null;
|
||||
const [url, setUrl] = useState<string | null>(() =>
|
||||
marketplaceURL ?? (key ? iconCache.get(key)?.url ?? null : null),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (marketplaceURL) {
|
||||
// Cloud/Space marketplace surface: already resolved upstream.
|
||||
setUrl(marketplaceURL);
|
||||
return;
|
||||
}
|
||||
if (!key || !author || !name) {
|
||||
setUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
iconRefCounts.set(key, (iconRefCounts.get(key) ?? 0) + 1);
|
||||
|
||||
const cached = iconCache.get(key);
|
||||
if (cached) {
|
||||
setUrl(cached.url);
|
||||
} else {
|
||||
setUrl(null);
|
||||
void loadIcon(key, author, name).then((objectURL) => {
|
||||
if (!objectURL) return;
|
||||
// The last subscriber may have left while the fetch was in flight.
|
||||
if (iconRefCounts.get(key) === undefined) {
|
||||
URL.revokeObjectURL(objectURL);
|
||||
return;
|
||||
}
|
||||
const current = iconCache.get(key);
|
||||
if (current) {
|
||||
// Another subscriber resolved first; share its object URL.
|
||||
if (current.url !== objectURL) URL.revokeObjectURL(objectURL);
|
||||
if (active) setUrl(current.url);
|
||||
return;
|
||||
}
|
||||
iconCache.set(key, { url: objectURL });
|
||||
if (active) setUrl(objectURL);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
const remaining = (iconRefCounts.get(key) ?? 1) - 1;
|
||||
if (remaining > 0) {
|
||||
iconRefCounts.set(key, remaining);
|
||||
return;
|
||||
}
|
||||
iconRefCounts.delete(key);
|
||||
const entry = iconCache.get(key);
|
||||
if (entry) {
|
||||
iconCache.delete(key);
|
||||
URL.revokeObjectURL(entry.url);
|
||||
}
|
||||
};
|
||||
}, [key, author, name, marketplaceURL]);
|
||||
|
||||
return marketplaceURL ?? url;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useInstalledPluginIcon } from '@/app/infra/hooks/useInstalledPluginIcon';
|
||||
import {
|
||||
systemInfo,
|
||||
bootstrapWorkspaceSession,
|
||||
@@ -1904,6 +1905,30 @@ function runnerPluginId(runner: string) {
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function InstalledRunnerIcon({
|
||||
author,
|
||||
name,
|
||||
}: {
|
||||
author: string;
|
||||
name: string;
|
||||
}) {
|
||||
const iconURL = useInstalledPluginIcon(author, name);
|
||||
|
||||
if (!iconURL) {
|
||||
return (
|
||||
<Blocks className="size-10 shrink-0 rounded-md border bg-muted p-2 text-muted-foreground" />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded-md border bg-muted object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function StepAIEngine({
|
||||
choice,
|
||||
onChoiceChange,
|
||||
@@ -2298,11 +2323,7 @@ function StepAIEngine({
|
||||
>
|
||||
<CardHeader className="flex flex-row items-start gap-3 px-4">
|
||||
{author && name ? (
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(author, name)}
|
||||
alt=""
|
||||
className="size-10 shrink-0 rounded-md border bg-muted object-cover"
|
||||
/>
|
||||
<InstalledRunnerIcon author={author} name={name} />
|
||||
) : (
|
||||
<Blocks className="size-10 shrink-0 rounded-md border bg-muted p-2 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user