feat(web): add icons/emoji to selectors, sync bot enable status and plugin list in sidebar

- Bot adapter selector: show adapter icon in trigger and dropdown items
- Knowledge engine selector: show plugin icon derived from plugin_id
- Pipeline binding selector: show pipeline emoji in trigger and dropdown items
- Knowledge base selectors (single/multi): show KB emoji in all views
- Sidebar bot entries: show green/gray status dot on adapter icon for enable/disable state
- Sidebar plugin list: sync after install/uninstall from all entry points (PluginInstalledComponent, plugins page, marketplace page)
- Pipeline form: add cursor-pointer to left-side tab list buttons
- Clean up unused onBotDeleted prop from BotForm
This commit is contained in:
Junyan Qin
2026-03-27 14:51:15 +08:00
parent 127dc455c3
commit bc3199bf29
11 changed files with 178 additions and 31 deletions
@@ -144,7 +144,6 @@ export default function BotDetailContent({ id }: { id: string }) {
<BotForm <BotForm
initBotId={undefined} initBotId={undefined}
onFormSubmit={handleFormSubmit} onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated} onNewBotCreated={handleNewBotCreated}
/> />
</div> </div>
@@ -238,7 +237,6 @@ export default function BotDetailContent({ id }: { id: string }) {
<BotForm <BotForm
initBotId={id} initBotId={id}
onFormSubmit={handleFormSubmit} onFormSubmit={handleFormSubmit}
onBotDeleted={handleBotDeleted}
onNewBotCreated={handleNewBotCreated} onNewBotCreated={handleNewBotCreated}
onDirtyChange={setFormDirty} onDirtyChange={setFormDirty}
/> />
@@ -65,13 +65,11 @@ const getFormSchema = (t: (key: string) => string) =>
export default function BotForm({ export default function BotForm({
initBotId, initBotId,
onFormSubmit, onFormSubmit,
onBotDeleted,
onNewBotCreated, onNewBotCreated,
onDirtyChange, onDirtyChange,
}: { }: {
initBotId?: string; initBotId?: string;
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void; onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
onBotDeleted: () => void;
onNewBotCreated: (botId: string) => void; onNewBotCreated: (botId: string) => void;
onDirtyChange?: (dirty: boolean) => void; onDirtyChange?: (dirty: boolean) => void;
}) { }) {
@@ -234,6 +232,7 @@ export default function BotForm({
return { return {
label: item.name, label: item.name,
value: item.uuid ?? '', value: item.uuid ?? '',
emoji: item.emoji,
}; };
}), }),
); );
@@ -461,13 +460,40 @@ export default function BotForm({
<FormControl> <FormControl>
<Select onValueChange={field.onChange} {...field}> <Select onValueChange={field.onChange} {...field}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={t('bots.selectPipeline')} /> {field.value ? (
(() => {
const pipeline = pipelineNameList.find(
(p) => p.value === field.value,
);
return (
<div className="flex items-center gap-2">
{pipeline?.emoji && (
<span className="text-sm shrink-0">
{pipeline.emoji}
</span>
)}
<span>{pipeline?.label ?? field.value}</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('bots.selectPipeline')}
/>
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{pipelineNameList.map((item) => ( {pipelineNameList.map((item) => (
<SelectItem key={item.value} value={item.value}> <SelectItem key={item.value} value={item.value}>
{item.label} <div className="flex items-center gap-2">
{item.emoji && (
<span className="text-sm shrink-0">
{item.emoji}
</span>
)}
<span>{item.label}</span>
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -508,13 +534,35 @@ export default function BotForm({
value={field.value} value={field.value}
> >
<SelectTrigger className="w-[240px]"> <SelectTrigger className="w-[240px]">
<SelectValue placeholder={t('bots.selectAdapter')} /> {field.value ? (
<div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(field.value)}
alt=""
className="h-5 w-5 rounded"
/>
<span>
{adapterNameList.find(
(a) => a.value === field.value,
)?.label ?? field.value}
</span>
</div>
) : (
<SelectValue placeholder={t('bots.selectAdapter')} />
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{adapterNameList.map((item) => ( {adapterNameList.map((item) => (
<SelectItem key={item.value} value={item.value}> <SelectItem key={item.value} value={item.value}>
{item.label} <div className="flex items-center gap-2">
<img
src={httpClient.getAdapterIconURL(item.value)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{item.label}</span>
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -6,4 +6,5 @@ export interface IChooseAdapterEntity {
export interface IPipelineEntity { export interface IPipelineEntity {
label: string; label: string;
value: string; value: string;
emoji?: string;
} }
@@ -541,7 +541,25 @@ export default function DynamicFormItemComponent({
return ( return (
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} /> {field.value && field.value !== '__none__' ? (
(() => {
const selectedKb = knowledgeBases.find(
(kb) => kb.uuid === field.value,
);
return (
<div className="flex items-center gap-2">
{selectedKb?.emoji && (
<span className="text-sm shrink-0">
{selectedKb.emoji}
</span>
)}
<span>{selectedKb?.name ?? field.value}</span>
</div>
);
})()
) : (
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />
)}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
@@ -553,7 +571,12 @@ export default function DynamicFormItemComponent({
<SelectLabel>{engineName}</SelectLabel> <SelectLabel>{engineName}</SelectLabel>
{kbs.map((base) => ( {kbs.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}> <SelectItem key={base.uuid} value={base.uuid ?? ''}>
{base.name} <div className="flex items-center gap-2">
{base.emoji && (
<span className="text-sm shrink-0">{base.emoji}</span>
)}
<span>{base.name}</span>
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -597,6 +620,11 @@ export default function DynamicFormItemComponent({
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium flex items-center gap-2"> <div className="font-medium flex items-center gap-2">
{currentKb.emoji && (
<span className="text-sm shrink-0">
{currentKb.emoji}
</span>
)}
{currentKb.name} {currentKb.name}
{currentKb.knowledge_engine?.name && ( {currentKb.knowledge_engine?.name && (
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300"> <span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
@@ -686,7 +714,14 @@ export default function DynamicFormItemComponent({
aria-label={`Select ${base.name}`} aria-label={`Select ${base.name}`}
/> />
<div className="flex-1"> <div className="flex-1">
<div className="font-medium">{base.name}</div> <div className="font-medium flex items-center gap-2">
{base.emoji && (
<span className="text-sm shrink-0">
{base.emoji}
</span>
)}
{base.name}
</div>
{base.description && ( {base.description && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{base.description} {base.description}
@@ -77,6 +77,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible'; } from '@/components/ui/collapsible';
import { ChevronRight, Plus } from 'lucide-react'; import { ChevronRight, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext'; import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
// Compare two version strings, returns true if v1 > v2 // Compare two version strings, returns true if v1 > v2
@@ -316,6 +317,7 @@ function NavItems({
const canCreate = CREATABLE_CATEGORIES.includes(config.id); const canCreate = CREATABLE_CATEGORIES.includes(config.id);
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id); const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(config.id);
const isPlugin = config.id === 'plugins'; const isPlugin = config.id === 'plugins';
const isBot = config.id === 'bots';
const isActive = const isActive =
selectedChild?.id === config.id || selectedChild?.id === config.id ||
pathname === routePrefix || pathname === routePrefix ||
@@ -391,9 +393,9 @@ function NavItems({
> >
<a <a
href={itemRoute} href={itemRoute}
className={ className={cn(
isPlugin && !item.debug ? 'pr-6' : '' isPlugin && !item.debug ? 'pr-6' : '',
} )}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
router.push(itemRoute); router.push(itemRoute);
@@ -404,11 +406,23 @@ function NavItems({
{item.emoji} {item.emoji}
</span> </span>
) : item.iconURL ? ( ) : item.iconURL ? (
<img <span className="relative shrink-0">
src={item.iconURL} <img
alt="" src={item.iconURL}
className="size-4 rounded shrink-0" alt=""
/> className="size-4 rounded"
/>
{isBot && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-sidebar',
item.enabled === false
? 'bg-muted-foreground/40'
: 'bg-green-500',
)}
/>
)}
</span>
) : null} ) : null}
<span className="truncate">{item.name}</span> <span className="truncate">{item.name}</span>
{item.debug && ( {item.debug && (
@@ -18,6 +18,8 @@ export interface SidebarEntityItem {
emoji?: string; emoji?: string;
iconURL?: string; iconURL?: string;
updatedAt?: string; // ISO timestamp for sorting by most recently edited updatedAt?: string; // ISO timestamp for sorting by most recently edited
// Bot-specific fields
enabled?: boolean;
// Plugin-specific fields // Plugin-specific fields
installSource?: string; installSource?: string;
installInfo?: Record<string, unknown>; installInfo?: Record<string, unknown>;
@@ -63,6 +65,7 @@ export function SidebarDataProvider({
name: bot.name, name: bot.name,
iconURL: httpClient.getAdapterIconURL(bot.adapter), iconURL: httpClient.getAdapterIconURL(bot.adapter),
updatedAt: bot.updated_at, updatedAt: bot.updated_at,
enabled: bot.enable ?? true,
})), })),
); );
} catch (error) { } catch (error) {
@@ -310,19 +310,58 @@ export default function KBForm({
value={field.value} value={field.value}
> >
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]"> <SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue {field.value ? (
placeholder={t('knowledge.selectKnowledgeEngine')} (() => {
/> const [author, name] = field.value.split('/');
const engine = ragEngines.find(
(e) => e.plugin_id === field.value,
);
return (
<div className="flex items-center gap-2">
<img
src={httpClient.getPluginIconURL(
author,
name,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>
{engine
? extractI18nObject(engine.name)
: field.value}
</span>
</div>
);
})()
) : (
<SelectValue
placeholder={t('knowledge.selectKnowledgeEngine')}
/>
)}
</SelectTrigger> </SelectTrigger>
<SelectContent className="fixed z-[1000]"> <SelectContent className="fixed z-[1000]">
{ragEngines.map((engine) => ( {ragEngines.map((engine) => {
<SelectItem const [author, name] = engine.plugin_id.split('/');
key={engine.plugin_id} return (
value={engine.plugin_id} <SelectItem
> key={engine.plugin_id}
{extractI18nObject(engine.name)} value={engine.plugin_id}
</SelectItem> >
))} <div className="flex items-center gap-2">
<img
src={httpClient.getPluginIconURL(
author,
name,
)}
alt=""
className="h-5 w-5 rounded"
/>
<span>{extractI18nObject(engine.name)}</span>
</div>
</SelectItem>
);
})}
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
+3
View File
@@ -16,6 +16,7 @@ import { systemInfo } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PluginV4 } from '@/app/infra/entities/plugin'; import { PluginV4 } from '@/app/infra/entities/plugin';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
enum PluginInstallStatus { enum PluginInstallStatus {
ASK_CONFIRM = 'ask_confirm', ASK_CONFIRM = 'ask_confirm',
@@ -39,6 +40,7 @@ export default function MarketplacePage() {
function MarketplaceContent() { function MarketplaceContent() {
const { t } = useTranslation(); const { t } = useTranslation();
const { refreshPlugins } = useSidebarData();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [installInfo, setInstallInfo] = useState<Record<string, string>>({}); const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
const [pluginInstallStatus, setPluginInstallStatus] = const [pluginInstallStatus, setPluginInstallStatus] =
@@ -83,6 +85,7 @@ function MarketplaceContent() {
alreadySuccess = true; alreadySuccess = true;
} }
setModalOpen(false); setModalOpen(false);
refreshPlugins();
} }
} }
}); });
@@ -456,7 +456,7 @@ export default function PipelineFormComponent({
type="button" type="button"
onClick={() => setActiveSection(section.name)} onClick={() => setActiveSection(section.name)}
className={cn( className={cn(
'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left', 'w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left cursor-pointer',
activeSection === section.name activeSection === section.name
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground', : 'text-muted-foreground hover:bg-muted hover:text-foreground',
@@ -22,6 +22,7 @@ import { useTranslation } from 'react-i18next';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
export interface PluginInstalledComponentRef { export interface PluginInstalledComponentRef {
refreshPluginList: () => void; refreshPluginList: () => void;
@@ -37,6 +38,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
(props, ref) => { (props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { refreshPlugins } = useSidebarData();
const [pluginList, setPluginList] = useState<PluginCardVO[]>([]); const [pluginList, setPluginList] = useState<PluginCardVO[]>([]);
const [showOperationModal, setShowOperationModal] = useState(false); const [showOperationModal, setShowOperationModal] = useState(false);
const [operationType, setOperationType] = useState<PluginOperationType>( const [operationType, setOperationType] = useState<PluginOperationType>(
@@ -54,6 +56,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
toast.success(successMessage); toast.success(successMessage);
setShowOperationModal(false); setShowOperationModal(false);
getPluginList(); getPluginList();
refreshPlugins();
}, },
onError: () => { onError: () => {
// Error is already handled in the hook state // Error is already handled in the hook state
+3
View File
@@ -51,6 +51,7 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { systemInfo } from '@/app/infra/http/HttpClient'; import { systemInfo } from '@/app/infra/http/HttpClient';
import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { ApiRespPluginSystemStatus } from '@/app/infra/entities/api';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
enum PluginInstallStatus { enum PluginInstallStatus {
WAIT_INPUT = 'wait_input', WAIT_INPUT = 'wait_input',
@@ -93,6 +94,7 @@ export default function PluginConfigPage() {
function PluginListView() { function PluginListView() {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const { refreshPlugins } = useSidebarData();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [installSource, setInstallSource] = useState<string>('local'); const [installSource, setInstallSource] = useState<string>('local');
const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any const [installInfo] = useState<Record<string, any>>({}); // eslint-disable-line @typescript-eslint/no-explicit-any
@@ -167,6 +169,7 @@ function PluginListView() {
resetGithubState(); resetGithubState();
setModalOpen(false); setModalOpen(false);
pluginInstalledRef.current?.refreshPluginList(); pluginInstalledRef.current?.refreshPluginList();
refreshPlugins();
} }
} }
}); });