diff --git a/dashboard/src/components/config/ConfigSpecialEditors.tsx b/dashboard/src/components/config/ConfigSpecialEditors.tsx index 9876d0d4..c04f1c49 100644 --- a/dashboard/src/components/config/ConfigSpecialEditors.tsx +++ b/dashboard/src/components/config/ConfigSpecialEditors.tsx @@ -19,6 +19,7 @@ import { Dialog } from '@/components/headless/Dialog'; import { MdiIcon } from '@/components/icons/MdiIcon'; import { Button } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { SelectControl } from '@/components/ui/SelectControl'; import { confirmAction, toast } from '@/stores/feedback'; import { normalizeT2iPreview } from './configSpecialEditorsModel'; import { isConfigRecord, setConfigValue, type ConfigRecord } from './configFormModel'; @@ -229,14 +230,14 @@ export function T2ITemplateEditor() { value={name} /> ) : ( - setSelected(event.target.value)} value={selected}> + setSelected(event.target.value)} value={selected}> {templates.map((template) => ( {template.name} {template.name === active ? ` · ${label('applied')}` : ''} ))} - + )} diff --git a/dashboard/src/components/config/DynamicConfigForm.tsx b/dashboard/src/components/config/DynamicConfigForm.tsx index f7fbd24f..bf3b486b 100644 --- a/dashboard/src/components/config/DynamicConfigForm.tsx +++ b/dashboard/src/components/config/DynamicConfigForm.tsx @@ -8,6 +8,7 @@ import { MdiIcon } from '@/components/icons/MdiIcon'; import { ExpandCollapse } from '@/components/motion/ExpandCollapse'; import { Button } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { SelectControl } from '@/components/ui/SelectControl'; import { toast } from '@/stores/feedback'; import { ConfigSpecialSelector, isConfigSelectorSpecial, PersonaQuickPreview } from './ConfigSpecialControls'; import { DashboardTotpManager, T2ITemplateEditor } from './ConfigSpecialEditors'; @@ -594,7 +595,7 @@ function ConfigControl({ if (metadata.options?.length) { const selectedIndex = metadata.options.findIndex((option) => Object.is(option, value)); return ( - onChange(metadata.options?.[Number(event.target.value)])} value={selectedIndex < 0 ? '' : selectedIndex} @@ -605,7 +606,7 @@ function ConfigControl({ {String(labels[index] ?? option)} ))} - + ); } diff --git a/dashboard/src/components/config/ObjectConfigControl.test.tsx b/dashboard/src/components/config/ObjectConfigControl.test.tsx index d2d268ca..5e7ba2a4 100644 --- a/dashboard/src/components/config/ObjectConfigControl.test.tsx +++ b/dashboard/src/components/config/ObjectConfigControl.test.tsx @@ -19,7 +19,8 @@ describe('ObjectConfigControl', () => { await user.clear(existingValue); await user.type(existingValue, 'after'); await user.type(screen.getByPlaceholderText('core.common.objectEditor.newKeyLabel'), 'retries'); - await user.selectOptions(screen.getByRole('combobox'), 'number'); + await user.click(screen.getByRole('button', { name: 'string' })); + await user.click(screen.getByRole('option', { name: 'number' })); await user.click(screen.getByRole('button', { name: /core\.common\.add/ })); const numberValue = screen.getByPlaceholderText('core.common.objectEditor.placeholders.numberValue'); diff --git a/dashboard/src/components/config/ObjectConfigControl.tsx b/dashboard/src/components/config/ObjectConfigControl.tsx index f5ec0eb0..8260c2d0 100644 --- a/dashboard/src/components/config/ObjectConfigControl.tsx +++ b/dashboard/src/components/config/ObjectConfigControl.tsx @@ -5,6 +5,7 @@ import { Dialog } from '@/components/headless/Dialog'; import { MdiIcon } from '@/components/icons/MdiIcon'; import { Button } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { SelectControl } from '@/components/ui/SelectControl'; import { toast } from '@/stores/feedback'; import { isConfigRecord, type ConfigItemMetadata, type ConfigRecord } from './configFormModel'; @@ -300,12 +301,12 @@ export function ObjectConfigControl({ /> {t('core.common.objectEditor.valueTypeLabel')} - setNewType(event.target.value as ObjectValueType)} value={newType}> + setNewType(event.target.value as ObjectValueType)} value={newType}> string number boolean json - + diff --git a/dashboard/src/components/ui/FloatingActions.test.tsx b/dashboard/src/components/ui/FloatingActions.test.tsx new file mode 100644 index 00000000..266d45e7 --- /dev/null +++ b/dashboard/src/components/ui/FloatingActions.test.tsx @@ -0,0 +1,26 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { FloatingActionButton, FloatingActions } from './FloatingActions'; + +describe('FloatingActions', () => { + it('portals actions to the document body with shared classes', () => { + render( + + + Refresh + + , + ); + + const action = screen.getByRole('button', { name: 'Refresh' }); + const stack = screen.getByLabelText('Page actions'); + + expect(action).toHaveClass('ui-floating-action'); + expect(action).toHaveAttribute('type', 'button'); + expect(stack).toHaveClass('ui-floating-actions'); + expect(stack.parentElement).toBe(document.body); + }); +}); diff --git a/dashboard/src/components/ui/FloatingActions.tsx b/dashboard/src/components/ui/FloatingActions.tsx new file mode 100644 index 00000000..bee1e6df --- /dev/null +++ b/dashboard/src/components/ui/FloatingActions.tsx @@ -0,0 +1,25 @@ +import { forwardRef, type ButtonHTMLAttributes, type HTMLAttributes } from 'react'; +import { createPortal } from 'react-dom'; + +export type FloatingActionsProps = HTMLAttributes; + +export function FloatingActions({ children, className = '', ...props }: FloatingActionsProps) { + if (typeof document === 'undefined') return null; + + return createPortal( + + {children} + , + document.body, + ); +} + +export const FloatingActionButton = forwardRef>( + function FloatingActionButton({ children, className = '', type = 'button', ...props }, ref) { + return ( + + {children} + + ); + }, +); diff --git a/dashboard/src/components/ui/Pagination.tsx b/dashboard/src/components/ui/Pagination.tsx index 3bf00f90..37cd951a 100644 --- a/dashboard/src/components/ui/Pagination.tsx +++ b/dashboard/src/components/ui/Pagination.tsx @@ -3,6 +3,7 @@ import { type ReactNode } from 'react'; import { MdiIcon } from '@/components/icons/MdiIcon'; import { paginationDefaults } from '@/config/defaults'; import { IconButton } from './IconButton'; +import { SelectControl } from './SelectControl'; export type PaginationLabels = { navigation: string; @@ -45,13 +46,13 @@ export function Pagination({ {onPageSizeChange ? ( {labels.pageSize} - onPageSizeChange(Number(event.target.value))} value={pageSize}> + onPageSizeChange(Number(event.target.value))} value={pageSize}> {pageSizeOptions.map((size) => ( {size} ))} - + ) : null} {labels.range ? {labels.range} : null} diff --git a/dashboard/src/components/ui/SelectControl.test.tsx b/dashboard/src/components/ui/SelectControl.test.tsx new file mode 100644 index 00000000..56a47d64 --- /dev/null +++ b/dashboard/src/components/ui/SelectControl.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ChangeEvent } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { SelectControl } from './SelectControl'; + +describe('SelectControl', () => { + it('preserves native change handlers while using the shared menu', async () => { + const user = userEvent.setup(); + let changedValue = ''; + const onChange = vi.fn((event: ChangeEvent) => { + changedValue = event.target.value; + }); + const view = render( + + 10 + 20 + , + ); + + await user.click(screen.getByRole('button', { name: 'Page size' })); + expect(view.container.querySelector('[role="listbox"]')).toBeNull(); + expect(document.body.querySelector('[role="listbox"]')).not.toBeNull(); + await user.click(screen.getByRole('option', { name: '20' })); + + expect(onChange).toHaveBeenCalledOnce(); + expect(changedValue).toBe('20'); + }); + + it('opens above the trigger when the viewport has no room below', async () => { + const user = userEvent.setup(); + render( + undefined} value="10"> + 10 + 20 + , + ); + const trigger = screen.getByRole('button', { name: 'Page size' }); + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ + bottom: 764, + height: 44, + left: 100, + right: 220, + top: 720, + width: 120, + x: 100, + y: 720, + toJSON: () => ({}), + }); + + await user.click(trigger); + + const listbox = screen.getByRole('listbox', { name: 'Page size' }); + await waitFor(() => expect(listbox.style.bottom).not.toBe('')); + expect(listbox.style.top).toBe(''); + }); +}); diff --git a/dashboard/src/components/ui/SelectControl.tsx b/dashboard/src/components/ui/SelectControl.tsx new file mode 100644 index 00000000..8339388c --- /dev/null +++ b/dashboard/src/components/ui/SelectControl.tsx @@ -0,0 +1,93 @@ +import { + Children, + Fragment, + isValidElement, + type ChangeEventHandler, + type OptionHTMLAttributes, + type ReactNode, + type SelectHTMLAttributes, + useMemo, + useRef, +} from 'react'; + +import { SelectMenu, type SelectMenuOption } from './SelectMenu'; + +type SelectControlProps = Omit, 'multiple' | 'size'> & { + children: ReactNode; +}; + +function optionLabel(children: ReactNode) { + return Children.toArray(children) + .map((child) => (typeof child === 'string' || typeof child === 'number' ? String(child) : '')) + .join(''); +} + +function collectOptions(children: ReactNode, options: SelectMenuOption[] = []) { + Children.forEach(children, (child) => { + if (!isValidElement(child)) return; + if (child.type === Fragment || child.type === 'optgroup') { + collectOptions((child.props as { children?: ReactNode }).children, options); + return; + } + if (child.type !== 'option') return; + const props = child.props as OptionHTMLAttributes; + const name = optionLabel(props.children); + options.push({ + disabled: props.disabled, + id: String(props.value ?? name), + name, + }); + }); + return options; +} + +export function SelectControl({ + 'aria-label': ariaLabel, + children, + className = '', + defaultValue, + disabled, + onChange, + value, + ...props +}: SelectControlProps) { + const nativeRef = useRef(null); + const options = useMemo(() => collectOptions(children), [children]); + const selectedValue = String(value ?? defaultValue ?? options[0]?.id ?? ''); + const selectedName = options.find((option) => option.id === selectedValue)?.name || ''; + + const selectValue = (nextValue: string) => { + const select = nativeRef.current; + if (!select) return; + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set; + setter?.call(select, nextValue); + select.dispatchEvent(new Event('change', { bubbles: true })); + }; + + return ( + <> + } + ref={nativeRef} + tabIndex={-1} + value={value} + defaultValue={value === undefined ? defaultValue : undefined} + > + {children} + + + > + ); +} diff --git a/dashboard/src/components/ui/SelectMenu.tsx b/dashboard/src/components/ui/SelectMenu.tsx new file mode 100644 index 00000000..f604b288 --- /dev/null +++ b/dashboard/src/components/ui/SelectMenu.tsx @@ -0,0 +1,144 @@ +import { createPortal } from 'react-dom'; +import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { MdiIcon } from '@/components/icons/MdiIcon'; + +export type SelectMenuOption = { + disabled?: boolean; + id: string; + image?: string; + name: string; +}; + +export function SelectMenu({ + ariaLabel, + className = '', + disabled = false, + imageForValue, + onChange, + options, + placeholder, + value, +}: { + ariaLabel: string; + className?: string; + disabled?: boolean; + imageForValue?: (value: string) => string | undefined; + onChange: (value: string) => void; + options: SelectMenuOption[]; + placeholder: string; + value: string; +}) { + const [open, setOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState({ visibility: 'hidden' }); + const menu = useRef(null); + const root = useRef(null); + const selected = options.find((option) => option.id === value); + + useEffect(() => { + if (!open) return undefined; + const close = (event: PointerEvent) => { + const target = event.target as Node; + if (!root.current?.contains(target) && !menu.current?.contains(target)) setOpen(false); + }; + document.addEventListener('pointerdown', close); + return () => document.removeEventListener('pointerdown', close); + }, [open]); + + useLayoutEffect(() => { + if (!open) return undefined; + const positionMenu = () => { + const trigger = root.current?.querySelector(':scope > button'); + if (!(trigger instanceof HTMLElement)) return; + const rect = trigger.getBoundingClientRect(); + const viewportWidth = document.documentElement.clientWidth || window.innerWidth; + const viewportHeight = document.documentElement.clientHeight || window.innerHeight; + const gap = 5; + const margin = 8; + const width = Math.min(Math.max(rect.width, 96), viewportWidth - margin * 2); + const left = Math.min(Math.max(rect.left, margin), viewportWidth - width - margin); + const measuredHeight = menu.current?.scrollHeight || Math.min(options.length * 42 + 10, 360); + const spaceBelow = viewportHeight - rect.bottom - margin; + const spaceAbove = rect.top - margin; + const openAbove = spaceBelow < Math.min(measuredHeight, 180) && spaceAbove > spaceBelow; + const availableHeight = Math.max(80, (openAbove ? spaceAbove : spaceBelow) - gap); + + setMenuStyle({ + bottom: openAbove ? viewportHeight - rect.top + gap : undefined, + left, + maxHeight: Math.min(360, availableHeight), + pointerEvents: 'auto', + top: openAbove ? undefined : rect.bottom + gap, + visibility: 'visible', + width, + }); + }; + + positionMenu(); + window.addEventListener('resize', positionMenu); + document.addEventListener('scroll', positionMenu, true); + return () => { + window.removeEventListener('resize', positionMenu); + document.removeEventListener('scroll', positionMenu, true); + }; + }, [open, options.length]); + + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + + return ( + + { + if (!open) setMenuStyle({ visibility: 'hidden' }); + setOpen((current) => !current); + }} + type="button" + > + {selected ? selected.name : placeholder} + + + {open && + typeof document !== 'undefined' && + createPortal( + event.stopPropagation()} + > + {options.map((option) => { + const image = option.image || imageForValue?.(option.id); + return ( + { + onChange(option.id); + setOpen(false); + }} + role="option" + type="button" + > + {image && } + {option.name} + {option.id === value && } + + ); + })} + , + document.body, + )} + + ); +} diff --git a/dashboard/src/components/ui/primitives.test.tsx b/dashboard/src/components/ui/primitives.test.tsx index 45c41e9f..1e392a2f 100644 --- a/dashboard/src/components/ui/primitives.test.tsx +++ b/dashboard/src/components/ui/primitives.test.tsx @@ -9,6 +9,8 @@ import { DisclosureButton } from './DisclosureButton'; import { DialogActions } from './DialogActions'; import { Pagination } from './Pagination'; import { SearchField } from './SearchField'; +import { SelectMenu } from './SelectMenu'; +import { SelectControl } from './SelectControl'; import { StatusChip } from './StatusChip'; const primitiveStyles = readFileSync(new URL('../../styles/components/_primitives.scss', import.meta.url), 'utf8'); @@ -46,6 +48,60 @@ describe('shared UI primitives', () => { expect(primitiveStyles).toContain('.button--warning'); }); + it('gives native form controls a theme-aware baseline', () => { + expect(primitiveStyles).toContain("input[type='text']"); + expect(primitiveStyles).toContain("input[type='checkbox']"); + expect(primitiveStyles).toContain('select,'); + expect(primitiveStyles).toContain('textarea'); + expect(primitiveStyles).toContain('var(--astrbot-radius-control)'); + expect(primitiveStyles).toContain(':focus'); + }); + + it('styles expanded native select pickers with a themed fallback', () => { + expect(primitiveStyles).toContain(':where(select option:checked)'); + expect(primitiveStyles).toContain('@supports (appearance: base-select)'); + expect(primitiveStyles).toContain('::picker(select)'); + expect(primitiveStyles).toContain('::picker-icon'); + expect(primitiveStyles).toContain('::checkmark'); + expect(primitiveStyles).toContain(':where(select:not(:disabled))'); + expect(primitiveStyles).toContain(':where(select:active, select:focus, select:focus-visible, select:open)'); + expect(primitiveStyles).toContain('border-color: transparent'); + }); + + it('shares the custom select menu used by feature pages', () => { + const markup = renderToStaticMarkup( + undefined} + options={[ + { id: 'local', name: 'Allow' }, + { id: 'none', name: 'Deny' }, + ]} + placeholder="Choose access" + value="none" + />, + ); + + expect(markup).toContain('ui-select-menu'); + expect(markup).toContain('aria-haspopup="listbox"'); + expect(markup).toContain('Deny'); + expect(markup).not.toContain('Choose access'); + }); + + it('adapts native option markup to the shared select menu', () => { + const markup = renderToStaticMarkup( + undefined}> + 10 + 20 + , + ); + + expect(markup).toContain('ui-select-control__native'); + expect(markup).toContain('aria-label="Page size"'); + expect(markup).toContain('aria-haspopup="listbox"'); + expect(markup).toContain('>20<'); + }); + it('gives search and status controls consistent accessible markup', () => { const markup = renderToStaticMarkup( <> diff --git a/dashboard/src/routes/chat/ChatComposer.test.tsx b/dashboard/src/routes/chat/ChatComposer.test.tsx index 66732752..ce858604 100644 --- a/dashboard/src/routes/chat/ChatComposer.test.tsx +++ b/dashboard/src/routes/chat/ChatComposer.test.tsx @@ -32,7 +32,9 @@ describe('ChatComposer', () => { />, ); - expect(markup).toContain('(fu {labels.config} - onConfigChange(event.target.value)} @@ -456,7 +457,7 @@ export const ChatComposer = forwardRef(fu {config.name} ))} - + {configs.find((config) => config.id === configId)?.description && ( {configs.find((config) => config.id === configId)?.description} )} diff --git a/dashboard/src/routes/chat/ChatMessageList.test.tsx b/dashboard/src/routes/chat/ChatMessageList.test.tsx new file mode 100644 index 00000000..c890ff9c --- /dev/null +++ b/dashboard/src/routes/chat/ChatMessageList.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { renderStatic } from '@/test/render'; +import { ChatMessageList } from './ChatMessageList'; + +describe('ChatMessageList', () => { + it('renders one lightweight thinking indicator while a reply is starting', () => { + const markup = renderStatic( + , + ); + + expect(markup.match(/ab-chat-message-spinner/g)).toHaveLength(1); + expect(markup).toContain('Thinking...'); + expect(markup).not.toContain('>Running<'); + }); +}); diff --git a/dashboard/src/routes/chat/ChatMessageList.tsx b/dashboard/src/routes/chat/ChatMessageList.tsx index b73fcfbe..02afd65c 100644 --- a/dashboard/src/routes/chat/ChatMessageList.tsx +++ b/dashboard/src/routes/chat/ChatMessageList.tsx @@ -44,6 +44,7 @@ export type ChatMessageLabels = { replyTo: string; cachedTokens: string; inputTokens: string; + loading: string; outputTokens: string; ttft: string; duration: string; @@ -127,6 +128,7 @@ export function ChatMessageList({ duration: t('features.chat.stats.duration'), edit: t('core.common.edit'), inputTokens: t('features.chat.stats.inputTokens'), + loading: t('features.chat.message.loading'), outputTokens: t('features.chat.stats.outputTokens'), reasoning: t('features.chat.reasoning.thinking'), references: t('features.chat.refs.title'), @@ -251,9 +253,7 @@ export function ChatMessageList({ ) : message.content.isLoading && bubbleParts.length === 0 ? ( - - {labels.running} - + {labels.loading} ) : ( bubbleParts.map((part, partIndex) => ( { beforeEach(() => { + resetChatStreamRegistry(); vi.resetAllMocks(); + localStorage.clear(); vi.mocked(listChatSessions).mockResolvedValue(mockApiResponse({ sessions: [] })); vi.mocked(listChatProjects).mockResolvedValue(mockApiResponse({ projects: [] })); vi.mocked(listProviders).mockResolvedValue(mockApiResponse({ model_metadata: {}, providers: [] })); @@ -103,6 +107,37 @@ describe('ChatPage', () => { ); }); + it('replaces a removed persisted provider before sending the first message', async () => { + const user = userEvent.setup(); + localStorage.setItem('selectedProvider', 'removed-provider'); + localStorage.setItem('selectedProviderModel', 'removed-model'); + vi.mocked(listProviders).mockResolvedValue( + mockApiResponse({ + model_metadata: {}, + providers: [{ enable: true, id: 'available-provider', model: 'available-model' }], + }), + ); + vi.mocked(createChatSession).mockResolvedValue(mockApiResponse({ session_id: 'session-new' })); + + renderRoute(, { route: '/chat' }); + + const composer = await screen.findByPlaceholderText('features.chat.input.placeholder'); + await waitFor(() => expect(selectedProviderPreference.read()).toBe('available-provider')); + expect(selectedModelPreference.read()).toBe('available-model'); + await user.type(composer, 'Use the available provider'); + await user.click(screen.getByRole('button', { name: 'features.chat.input.send' })); + + await waitFor(() => expect(runChatStream).toHaveBeenCalled()); + expect(runChatStream).toHaveBeenCalledWith( + expect.objectContaining({ + selectedModel: 'available-model', + selectedProvider: 'available-provider', + }), + expect.any(AbortSignal), + expect.any(Object), + ); + }); + it('keeps the newest conversation when requests resolve out of order', async () => { const user = userEvent.setup(); const firstRequest = deferred>>>(); @@ -134,4 +169,52 @@ describe('ChatPage', () => { await waitFor(() => expect(screen.queryByText('stale response')).not.toBeInTheDocument()); expect(screen.getByText('newest response')).toBeInTheDocument(); }); + + it('keeps an active chat stream alive while navigating to another dashboard page', async () => { + const user = userEvent.setup(); + const stream = deferred(); + let streamSignal: AbortSignal | undefined; + let deliverPayload: ((payload: unknown) => void) | undefined; + vi.mocked(getChatSession).mockResolvedValue(mockApiResponse({ history: [] })); + vi.mocked(runChatStream).mockImplementation(async (_action, signal, callbacks) => { + streamSignal = signal; + deliverPayload = callbacks.onPayload; + await stream.promise; + }); + + renderRoute( + <> + Open logs + Return to chat + + } path="/chat/:conversationId" /> + Logs page} path="/logs" /> + + >, + { route: '/chat/session-1' }, + ); + + const composer = await screen.findByPlaceholderText('features.chat.input.placeholder'); + await user.type(composer, 'Keep generating'); + await user.click(screen.getByRole('button', { name: 'features.chat.input.send' })); + await waitFor(() => expect(runChatStream).toHaveBeenCalled()); + expect(deliverPayload).toEqual(expect.any(Function)); + expect(chatStreamRegistry.messageCache['session-1']).toHaveLength(2); + + await user.click(screen.getByRole('link', { name: 'Open logs' })); + expect(await screen.findByText('Logs page')).toBeInTheDocument(); + expect(streamSignal?.aborted).toBe(false); + + await user.click(screen.getByRole('link', { name: 'Return to chat' })); + await screen.findByPlaceholderText('features.chat.input.placeholder'); + expect(chatStreamRegistry.messageCache['session-1']).toHaveLength(2); + expect(await screen.findByText('Keep generating')).toBeInTheDocument(); + deliverPayload?.({ type: 'plain', data: 'Still connected', streaming: true }); + expect(chatStreamRegistry.messageCache['session-1'][1].content.message).toContainEqual({ + text: 'Still connected', + type: 'plain', + }); + + stream.resolve(); + }); }); diff --git a/dashboard/src/routes/chat/ChatPage.tsx b/dashboard/src/routes/chat/ChatPage.tsx index 83439afa..58303f8c 100644 --- a/dashboard/src/routes/chat/ChatPage.tsx +++ b/dashboard/src/routes/chat/ChatPage.tsx @@ -67,6 +67,7 @@ import { } from './configBinding'; import { createStreamRenderScheduler } from './streamRenderScheduler'; import { runChatStream } from './chatTransport'; +import { chatStreamRegistry } from './chatStreamRegistry'; import { useChatPreferences } from './useChatPreferences'; import { agentRunnerTypeFromProfile, @@ -187,8 +188,8 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { const agentRunnerCacheRef = useRef(new Map()); const agentRunnerRequestsRef = useRef(new Map>()); const agentRunnerRequestIdRef = useRef(0); - const activeStreamsRef = useRef>(new Map()); - const messageCacheRef = useRef>({}); + const activeStreamsRef = useRef(chatStreamRegistry.activeStreams); + const messageCacheRef = useRef(chatStreamRegistry.messageCache); const activeConversationRef = useRef(conversationId); const audioRecorderRef = useRef(new AudioRecorder()); const activeSessionRef = useRef(''); @@ -428,14 +429,17 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { setProviderMetadata( isObject(envelope.model_metadata) ? (envelope.model_metadata as Record) : {}, ); - const selected = items.find((item) => item.id === provider); - if (selected?.model) setModel(selected.model); + const selected = items.find((item) => item.id === provider) || items[0]; + const selectedProvider = selected?.id || ''; + const selectedModel = selected?.model || ''; + if (provider !== selectedProvider) setProvider(selectedProvider); + setModel(selectedModel); } catch (cause) { toast.error(errorMessage(cause, 'Failed to load models.')); } finally { setProvidersLoading(false); } - }, [provider]); + }, [provider, setModel, setProvider]); const loadMessages = useCallback(async () => { const requestId = ++messageLoadRequestRef.current; @@ -554,10 +558,17 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { void loadMessages(); }, [conversationId, loadMessages]); + useEffect(() => { + if (!conversationId) return; + return chatStreamRegistry.subscribe(conversationId, () => { + const cached = messageCacheRef.current[conversationId]; + if (cached) setMessages([...cached]); + markSessionRunning(conversationId, activeStreamsRef.current.has(conversationId)); + }); + }, [conversationId, markSessionRunning]); + useEffect( () => () => { - activeStreamsRef.current.forEach((controller) => controller.abort()); - activeStreamsRef.current.clear(); audioRecorderRef.current.cancel(); if (settingsSubmenuTimer.current != null) window.clearTimeout(settingsSubmenuTimer.current); if (messageScrollFrame.current != null) window.cancelAnimationFrame(messageScrollFrame.current); @@ -965,6 +976,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { abort = new AbortController(); abortRef.current = abort; activeStreamsRef.current.set(sessionId, abort); + chatStreamRegistry.notify(sessionId); activeSessionRef.current = sessionId; const messagePayload = serializeChatParts(outgoing); const applyPayloads = (payloads: unknown[]) => { @@ -976,8 +988,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { if (changed) streamRender?.schedule(); }; streamRender = createStreamRenderScheduler(() => { - const cached = messageCacheRef.current[sessionId]; - if (activeConversationRef.current === sessionId && cached?.includes(bot!)) setMessages([...cached]); + chatStreamRegistry.notify(sessionId); }); await runChatStream( { @@ -986,8 +997,8 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { enableStreaming: streaming, message: messagePayload, messageId, - selectedModel: providerOverrideEnabled ? model : '', - selectedProvider: providerOverrideEnabled ? provider : '', + selectedModel: providerOverrideEnabled ? currentProvider?.model || '' : '', + selectedProvider: providerOverrideEnabled ? currentProvider?.id || '' : '', sessionId, transport: transportMode, }, @@ -1012,12 +1023,12 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { streamRender.schedule(); streamRender.flush(); } else { - const cached = messageCacheRef.current[sessionId]; - if (activeConversationRef.current === sessionId && cached?.includes(bot)) setMessages([...cached]); + chatStreamRegistry.notify(sessionId); } } if (!abort || abortRef.current === abort) abortRef.current = null; if (sessionId) activeStreamsRef.current.delete(sessionId); + if (sessionId) chatStreamRegistry.notify(sessionId); if (!sessionId || activeSessionRef.current === sessionId) activeSessionRef.current = ''; if (sessionId) markSessionRunning(sessionId, false); setPendingSessionSending(false); @@ -1026,7 +1037,11 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { } }; - const regenerate = async (target: ChatRecord, selectedProvider = provider, selectedModel = model) => { + const regenerate = async ( + target: ChatRecord, + selectedProvider = currentProvider?.id || '', + selectedModel = currentProvider?.model || '', + ) => { if (!conversationId || target.id == null || sending) return; const targetId = String(target.id); const index = messages.findIndex((item) => item === target || String(item.id) === targetId); @@ -1046,11 +1061,11 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { setError(''); const abort = new AbortController(); const streamRender = createStreamRenderScheduler(() => { - const cached = messageCacheRef.current[conversationId]; - if (activeConversationRef.current === conversationId && cached?.includes(regenerated)) setMessages([...cached]); + chatStreamRegistry.notify(conversationId); }); abortRef.current = abort; activeStreamsRef.current.set(conversationId, abort); + chatStreamRegistry.notify(conversationId); activeSessionRef.current = conversationId; try { await runChatStream( @@ -1083,6 +1098,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { streamRender.flush(); if (abortRef.current === abort) abortRef.current = null; activeStreamsRef.current.delete(conversationId); + chatStreamRegistry.notify(conversationId); if (activeSessionRef.current === conversationId) activeSessionRef.current = ''; markSessionRunning(conversationId, false); } @@ -1112,8 +1128,9 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { markSessionRunning(conversationId, true); const abort = new AbortController(); activeStreamsRef.current.set(conversationId, abort); + chatStreamRegistry.notify(conversationId); const scheduler = createStreamRenderScheduler(() => { - if (activeConversationRef.current === conversationId) setMessages([...messageCacheRef.current[conversationId]]); + chatStreamRegistry.notify(conversationId); }); try { await runChatStream( @@ -1123,8 +1140,8 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { enableStreaming: streaming, llmCheckpointId: String(source.llm_checkpoint_id || ''), message: serializeChatParts(source.content.message), - selectedModel: providerOverrideEnabled ? model : '', - selectedProvider: providerOverrideEnabled ? provider : '', + selectedModel: providerOverrideEnabled ? currentProvider?.model || '' : '', + selectedProvider: providerOverrideEnabled ? currentProvider?.id || '' : '', sessionId: conversationId, }, abort.signal, @@ -1145,6 +1162,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { scheduler.schedule(); scheduler.flush(); activeStreamsRef.current.delete(conversationId); + chatStreamRegistry.notify(conversationId); markSessionRunning(conversationId, false); } }; @@ -1273,8 +1291,8 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { kind: 'thread', enableStreaming: streaming, message: [{ type: 'plain', text }], - selectedModel: providerOverrideEnabled ? model : '', - selectedProvider: providerOverrideEnabled ? provider : '', + selectedModel: providerOverrideEnabled ? currentProvider?.model || '' : '', + selectedProvider: providerOverrideEnabled ? currentProvider?.id || '' : '', threadId, }, abort.signal, @@ -1353,6 +1371,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { if (sessionId) await stopChatSession({ path: { session_id: sessionId } }).catch(() => undefined); if (sessionId) { activeStreamsRef.current.delete(sessionId); + chatStreamRegistry.notify(sessionId); markSessionRunning(sessionId, false); } setPendingSessionSending(false); @@ -1590,7 +1609,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { {runningSessionIds.has(session.session_id) && ( - + )} )) @@ -1610,7 +1629,7 @@ export default function ChatPage({ chatbox = false }: ChatPageProps) { selectSession(session.session_id)} type="button"> {session.display_name || session.session_id} {runningSessionIds.has(session.session_id) && ( - + )} diff --git a/dashboard/src/routes/chat/ChatProjectDialog.tsx b/dashboard/src/routes/chat/ChatProjectDialog.tsx index 8782bd4b..a254209e 100644 --- a/dashboard/src/routes/chat/ChatProjectDialog.tsx +++ b/dashboard/src/routes/chat/ChatProjectDialog.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Dialog } from '@/components/headless/Dialog'; import { Button } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { SelectControl } from '@/components/ui/SelectControl'; export type ChatProjectForm = { description: string; @@ -109,7 +110,7 @@ export function ChatProjectDialog({ {t('features.chat.project.workspace.type')} - set('workspace_type', event.target.value as ChatProjectForm['workspace_type'])} value={form.workspace_type} @@ -117,7 +118,7 @@ export function ChatProjectDialog({ {t('features.chat.project.workspace.project')} {t('features.chat.project.workspace.session')} {t('features.chat.project.workspace.custom')} - + {form.workspace_type === 'custom' && ( diff --git a/dashboard/src/routes/chat/chatStreamRegistry.ts b/dashboard/src/routes/chat/chatStreamRegistry.ts new file mode 100644 index 00000000..32992cf7 --- /dev/null +++ b/dashboard/src/routes/chat/chatStreamRegistry.ts @@ -0,0 +1,34 @@ +import type { ChatRecord } from './model'; + +type StreamListener = () => void; + +const activeStreams = new Map(); +const messageCache: Record = {}; +const listeners = new Map>(); + +export const chatStreamRegistry = { + activeStreams, + messageCache, + + notify(sessionId: string) { + listeners.get(sessionId)?.forEach((listener) => listener()); + }, + + subscribe(sessionId: string, listener: StreamListener) { + const sessionListeners = listeners.get(sessionId) ?? new Set(); + sessionListeners.add(listener); + listeners.set(sessionId, sessionListeners); + listener(); + return () => { + sessionListeners.delete(listener); + if (!sessionListeners.size) listeners.delete(sessionId); + }; + }, +}; + +export function resetChatStreamRegistry() { + activeStreams.forEach((controller) => controller.abort()); + activeStreams.clear(); + Object.keys(messageCache).forEach((sessionId) => delete messageCache[sessionId]); + listeners.clear(); +} diff --git a/dashboard/src/routes/configuration/ApiKeySettingsSection.tsx b/dashboard/src/routes/configuration/ApiKeySettingsSection.tsx index e68464df..837fb1f6 100644 --- a/dashboard/src/routes/configuration/ApiKeySettingsSection.tsx +++ b/dashboard/src/routes/configuration/ApiKeySettingsSection.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { createApiKey, deleteApiKey, listApiKeys, revokeApiKey } from '@/api/openapi'; import { MdiIcon } from '@/components/icons/MdiIcon'; +import { SelectControl } from '@/components/ui/SelectControl'; import { externalLinks } from '@/config/links'; import { useBrowserCapabilities } from '@/platform/BrowserCapabilitiesProvider'; import { confirmAction, toast } from '@/stores/feedback'; @@ -152,7 +153,7 @@ export function ApiKeySettingsSection() { placeholder={t(`${prefix}.name`)} value={name} /> - setExpiry(event.target.value === 'permanent' ? 'permanent' : Number(event.target.value))} @@ -163,7 +164,7 @@ export function ApiKeySettingsSection() { {t(`${prefix}.expiryOptions.day30`)} {t(`${prefix}.expiryOptions.day90`)} {t(`${prefix}.expiryOptions.permanent`)} - + void create()} type="button"> {t(`${prefix}.create`)} diff --git a/dashboard/src/routes/configuration/ConfigPage.tsx b/dashboard/src/routes/configuration/ConfigPage.tsx index f161c16a..7bcb4c94 100644 --- a/dashboard/src/routes/configuration/ConfigPage.tsx +++ b/dashboard/src/routes/configuration/ConfigPage.tsx @@ -17,6 +17,7 @@ import { DEFAULT_CONFIG_ID } from '@/config/defaults'; import { MetadataConfigEditor } from '@/components/config/DynamicConfigForm'; import { isConfigRecord, type ConfigRecord } from '@/components/config/configFormModel'; import { MdiIcon } from '@/components/icons/MdiIcon'; +import { SelectControl } from '@/components/ui/SelectControl'; import { Dialog, DialogClose } from '@/components/headless/Dialog'; import { confirmAction, toast } from '@/stores/feedback'; import { JsonConfigDialog, LoadingState } from './ConfigurationUi'; @@ -295,7 +296,7 @@ export default function ConfigPage() { {t('features.config.configSelection.selectConfig')} - void chooseProfile(event.target.value)} value={selected} @@ -306,7 +307,7 @@ export default function ConfigPage() { ))} {t('features.config.configManagement.manageConfigs')} - + diff --git a/dashboard/src/routes/configuration/CronPage.tsx b/dashboard/src/routes/configuration/CronPage.tsx index c374e196..b1f35385 100644 --- a/dashboard/src/routes/configuration/CronPage.tsx +++ b/dashboard/src/routes/configuration/CronPage.tsx @@ -15,6 +15,7 @@ import { MdiIcon } from '@/components/icons/MdiIcon'; import { Menu, MenuItem } from '@/components/headless/Menu'; import { Button, DialogCancel } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { SelectControl } from '@/components/ui/SelectControl'; import { toast } from '@/stores/feedback'; import { buildCronExpression, @@ -453,7 +454,7 @@ export default function CronPage() { - setTargetFilter(event.target.value)} value={targetFilter}> + setTargetFilter(event.target.value)} value={targetFilter}> {k('filters.umo')} {jobs.some((job) => !jobSession(job)) && ( {k('filters.noDeliveryTarget')} @@ -463,7 +464,7 @@ export default function CronPage() { {target} ))} - + )} @@ -617,7 +618,7 @@ export default function CronPage() { {k('form.scheduleMode')} - updateForm('scheduleMode', event.target.value as ScheduleMode)} value={form.scheduleMode} > @@ -626,7 +627,7 @@ export default function CronPage() { {k(`form.scheduleModes.${mode}`)} ))} - + @@ -680,7 +681,7 @@ function ScheduleFields({ form, k, updateForm }: ScheduleFieldsProps) { {k('form.intervalUnit')} - updateForm('intervalUnit', event.target.value as IntervalUnit)} value={form.intervalUnit} > @@ -689,7 +690,7 @@ function ScheduleFields({ form, k, updateForm }: ScheduleFieldsProps) { {k(`form.intervalUnits.${unit}`)} ))} - + ); @@ -705,13 +706,16 @@ function ScheduleFields({ form, k, updateForm }: ScheduleFieldsProps) { {k('form.weeklyDay')} - updateForm('weeklyDay', Number(event.target.value))} value={form.weeklyDay}> + updateForm('weeklyDay', Number(event.target.value))} + value={form.weeklyDay} + > {['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].map((day, index) => ( {k(`form.weekdays.${day}`)} ))} - + {k('form.weeklyTime')} diff --git a/dashboard/src/routes/configuration/PlatformPage.tsx b/dashboard/src/routes/configuration/PlatformPage.tsx index 8bb0e0f0..2696c44c 100644 --- a/dashboard/src/routes/configuration/PlatformPage.tsx +++ b/dashboard/src/routes/configuration/PlatformPage.tsx @@ -21,6 +21,8 @@ import type { ConfigGroupMetadata, ConfigRecord } from '@/components/config/conf import { Dialog, DialogClose } from '@/components/headless/Dialog'; import { MdiIcon } from '@/components/icons/MdiIcon'; import { DisclosureButton } from '@/components/ui/DisclosureButton'; +import { SelectMenu } from '@/components/ui/SelectMenu'; +import { SelectControl } from '@/components/ui/SelectControl'; import { DEFAULT_CONFIG_ID } from '@/config/defaults'; import { useBrowserCapabilities } from '@/platform/BrowserCapabilitiesProvider'; import { i18n } from '@/i18n'; @@ -499,73 +501,6 @@ function PlatformCard({ ); } -function PlatformSelect({ - ariaLabel, - imageForValue, - onChange, - options, - placeholder, - value, -}: { - ariaLabel: string; - imageForValue?: (value: string) => string | undefined; - onChange: (value: string) => void; - options: ConfigProfileOption[]; - placeholder: string; - value: string; -}) { - const [open, setOpen] = useState(false); - const root = useRef(null); - const selected = options.find((option) => option.id === value); - useEffect(() => { - if (!open) return undefined; - const close = (event: PointerEvent) => { - if (!root.current?.contains(event.target as Node)) setOpen(false); - }; - document.addEventListener('pointerdown', close); - return () => document.removeEventListener('pointerdown', close); - }, [open]); - return ( - - setOpen((current) => !current)} - type="button" - > - {selected ? selected.name : placeholder} - - - {open && ( - - {options.map((option) => { - const image = imageForValue?.(option.id); - return ( - { - onChange(option.id); - setOpen(false); - }} - role="option" - type="button" - > - {image && } - {option.name} - {option.id === value && } - - ); - })} - - )} - - ); -} - function PlatformEditor({ configMode, configProfiles, @@ -662,7 +597,7 @@ function PlatformEditor({ {t('createDialog.step1Hint')} {!editing && ( - platformLogo(String(templates[key]?.type || key), templates[key])} onChange={onTypeChange} @@ -776,7 +711,7 @@ function PlatformEditor({ {t('createDialog.selectConfigLabel')} - ( - { const parsed = parsePlatformUmo(event.target.value); @@ -913,8 +848,8 @@ function PlatformRoutesEditor({ {umo} ))} - - + update(index, { messageType: event.target.value, sourceUmo: '' })} value={route.messageType} @@ -922,14 +857,14 @@ function PlatformRoutesEditor({ {t('createDialog.messageTypeOptions.all')} {t('createDialog.messageTypeOptions.group')} {t('createDialog.messageTypeOptions.friend')} - + update(index, { sessionId: event.target.value || '*', sourceUmo: '' })} placeholder={t('createDialog.sessionIdPlaceholder')} value={route.sessionId} /> - update(index, { configId: event.target.value })} value={route.configId} @@ -939,7 +874,7 @@ function PlatformRoutesEditor({ {profile.name} ))} - + move(index, -1)} type="button"> diff --git a/dashboard/src/routes/extensions/ExtensionPage.tsx b/dashboard/src/routes/extensions/ExtensionPage.tsx index 3a963b34..51139f6b 100644 --- a/dashboard/src/routes/extensions/ExtensionPage.tsx +++ b/dashboard/src/routes/extensions/ExtensionPage.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import { @@ -28,11 +27,13 @@ import { Markdown } from '@/components/content/Markdown'; import { ConfigGroup } from '@/components/config/DynamicConfigForm'; import type { ConfigGroupMetadata } from '@/components/config/configFormModel'; import { Dialog } from '@/components/headless/Dialog'; +import { FloatingActionButton, FloatingActions } from '@/components/ui/FloatingActions'; import { MdiIcon } from '@/components/icons/MdiIcon'; import { AsyncState } from '@/components/ui/AsyncState'; import { FormDialog } from '@/components/ui/FormDialog'; import { Pagination } from '@/components/ui/Pagination'; import { SearchField } from '@/components/ui/SearchField'; +import { SelectControl } from '@/components/ui/SelectControl'; import { confirmDestructiveAction } from '@/components/ui/confirm'; import { confirmAction, toast } from '@/stores/feedback'; import { errorMessage, isObject, type JsonObject, recordId, responseData } from '@/routes/configuration/model'; @@ -749,33 +750,24 @@ function InstalledPlugins() { {e('empty.noPluginsDesc')} )} - {typeof document !== 'undefined' && - createPortal( - - void updateAll()} - title={e('buttons.updateAll')} - type="button" - > - - - setInstallOpen(true)} - title={e('market.installPlugin')} - type="button" - > - - - , - document.body, - )} + + void updateAll()} + title={e('buttons.updateAll')} + > + + + setInstallOpen(true)} + title={e('market.installPlugin')} + > + + + !open && setConfigPlugin(null)} open={configPlugin !== null} @@ -1236,15 +1228,15 @@ function PluginMarket() { value={keyword} /> - setInstalling({})} - title={e('market.installPlugin')} - type="button" - > - - + + setInstalling({})} + title={e('market.installPlugin')} + > + + + {e('market.allPlugins')} @@ -1261,7 +1253,7 @@ function PluginMarket() { {e('market.category')} - { setCategory(event.target.value); setPage(1); @@ -1273,12 +1265,12 @@ function PluginMarket() { {item.label} ({item.count}) ))} - + {e('sort.by')} - { setSort(event.target.value as typeof sort); setPage(1); @@ -1289,7 +1281,7 @@ function PluginMarket() { {e('sort.stars')} {e('sort.author')} {e('sort.updated')} - + {sort !== 'default' && ( {t('core.common.itemsPerPage')}:{' '} - onPageSizeChange(Number(event.target.value))} value={pageSize}> + onPageSizeChange(Number(event.target.value))} + value={pageSize} + > 10 25 50 - + {t('core.common.paginationRange', { @@ -580,41 +588,60 @@ function CommandFilters({ }) { return ( - + {t('filters.byPlugin')} - onPluginChange(event.target.value)} value={plugin}> - {t('filters.all')} - {plugins.map((item) => ( - {item} - ))} - - - + ({ id: item, name: item }))]} + placeholder={t('filters.all')} + value={plugin} + /> + + {t('filters.byType')} - onTypeChange(event.target.value)} value={type}> - {t('filters.all')} - {t('type.group')} - {t('type.command')} - {t('type.subCommand')} - - - + + + {t('filters.byPermission')} - onPermissionChange(event.target.value)} value={permission}> - {t('filters.all')} - {t('permission.everyone')} - {t('permission.admin')} - - - + + + {t('filters.byStatus')} - onStatusChange(event.target.value)} value={status}> - {t('filters.all')} - {t('filters.enabled')} - {t('filters.disabled')} - {t('filters.conflict')} - - + + ); } @@ -688,14 +715,14 @@ function CommandRow({ - void onPermission(item, event.target.value as 'admin' | 'member')} value={item.permission === 'admin' ? 'admin' : 'member'} > {t('permission.everyone')} {t('permission.admin')} - + {t(`status.${status}`)} @@ -802,14 +829,14 @@ function ToolRow({ {item.origin === 'builtin' ? ( {t('functionTools.table.permissionBuiltin')} ) : ( - void onPermission(item, event.target.value as 'admin' | 'member')} value={item.permission === 'admin' ? 'admin' : 'member'} > {t('functionTools.table.permissionEveryone')} {t('functionTools.table.permissionAdmin')} - + )} @@ -1435,9 +1462,9 @@ export function McpSection() { {m('dialogs.syncProvider.fields.provider')} - + {m('dialogs.syncProvider.providers.modelscope')} - + @@ -1484,24 +1511,22 @@ export function McpSection() { function McpFloatingActions({ onAdd, onSync, t }: { onAdd: () => void; onSync: () => void; t: ModuleText }) { return ( - - + - - + - - + + ); } @@ -1939,21 +1964,20 @@ function SkillsFloatingActions({ t: ModuleText; }) { return ( - - + void onRefresh()} title={t('skills.refresh')} - type="button" > - + {mode === 'local' && ( - + - + )} - + ); } @@ -2528,7 +2552,7 @@ function NeoSkills({ {t('skills.neoStatus')} - onFilters((current) => ({ ...current, status: event.target.value }))} value={filters.status} > @@ -2538,18 +2562,18 @@ function NeoSkills({ {value} ), )} - + {t('skills.neoStage')} - onFilters((current) => ({ ...current, stage: event.target.value }))} value={filters.stage} > {t('skills.neoAll')} canary stable - + {loading && } diff --git a/dashboard/src/routes/knowledge/DocumentDetailPage.tsx b/dashboard/src/routes/knowledge/DocumentDetailPage.tsx index d9b88154..a2b12845 100644 --- a/dashboard/src/routes/knowledge/DocumentDetailPage.tsx +++ b/dashboard/src/routes/knowledge/DocumentDetailPage.tsx @@ -15,6 +15,7 @@ import { Dialog } from '@/components/headless/Dialog'; import { MdiIcon } from '@/components/icons/MdiIcon'; import { DialogCancel } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { SelectControl } from '@/components/ui/SelectControl'; import { confirmAction, toast } from '@/stores/feedback'; import { errorMessage, recordId } from '@/routes/configuration/model'; import { chunkCount, documentName, formatFileSize, formatKnowledgeDate } from './knowledgeModel'; @@ -218,7 +219,7 @@ export default function DocumentDetailPage() { {k('chunks.showing')} {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} / {total} - { setPageSize(Number(event.target.value)); setPage(1); @@ -228,7 +229,7 @@ export default function DocumentDetailPage() { {[10, 25, 50, 100].map((size) => ( {size} ))} - + setPage((value) => value - 1)} type="button"> ‹ diff --git a/dashboard/src/routes/knowledge/KnowledgeBaseDetailPage.tsx b/dashboard/src/routes/knowledge/KnowledgeBaseDetailPage.tsx index f5fd230d..513ddc37 100644 --- a/dashboard/src/routes/knowledge/KnowledgeBaseDetailPage.tsx +++ b/dashboard/src/routes/knowledge/KnowledgeBaseDetailPage.tsx @@ -31,6 +31,7 @@ import { Dialog } from '@/components/headless/Dialog'; import { MdiIcon } from '@/components/icons/MdiIcon'; import { Button, DialogCancel } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { SelectControl } from '@/components/ui/SelectControl'; import { confirmAction, toast } from '@/stores/feedback'; import { errorMessage, isObject, JsonObject, responseData } from '@/routes/configuration/model'; import { @@ -540,7 +541,7 @@ function Documents({ {t('upload.cleaningProvider')} - setUploadSettings({ ...uploadSettings, cleaning_provider_id: event.target.value }) @@ -553,7 +554,7 @@ function Documents({ {String(provider.id)} ))} - + {t('upload.cleaningProviderHint')} @@ -802,7 +803,7 @@ function KnowledgeDocumentList({ {total > pageSize && ( - { onPageSizeChange(Number(event.target.value)); onPageChange(1); @@ -812,7 +813,7 @@ function KnowledgeDocumentList({ {[10, 20, 50].map((size) => ( {size} ))} - + onPageChange((value) => value - 1)} type="button"> ‹ @@ -1015,7 +1016,7 @@ function KnowledgeSettings({ {t('settings.rerankProvider')} - setForm({ ...form, rerank_provider_id: event.target.value })} value={form.rerank_provider_id} > @@ -1025,7 +1026,7 @@ function KnowledgeSettings({ {String(provider.rerank_model || provider.model || provider.id)} ))} - + diff --git a/dashboard/src/routes/knowledge/KnowledgeBaseListPage.tsx b/dashboard/src/routes/knowledge/KnowledgeBaseListPage.tsx index 67b31c35..e0bf1e5d 100644 --- a/dashboard/src/routes/knowledge/KnowledgeBaseListPage.tsx +++ b/dashboard/src/routes/knowledge/KnowledgeBaseListPage.tsx @@ -17,6 +17,7 @@ import { MdiIcon } from '@/components/icons/MdiIcon'; import { AsyncState } from '@/components/ui/AsyncState'; import { Button, DialogCancel } from '@/components/ui/Button'; import { DialogActions } from '@/components/ui/DialogActions'; +import { FloatingActionButton, FloatingActions } from '@/components/ui/FloatingActions'; import { IconButton } from '@/components/ui/IconButton'; import { PageHeader } from '@/components/ui/PageHeader'; import { Pagination } from '@/components/ui/Pagination'; @@ -407,31 +408,19 @@ export default function KnowledgeBaseListPage() { totalItems={total} /> )} - {typeof document !== 'undefined' && - createPortal( - - void load()} - title={k('list.refresh')} - type="button" - > - - - open()} - title={k('list.create')} - type="button" - > - - - , - document.body, - )} + + void load()} + title={k('list.refresh')} + > + + + open()} title={k('list.create')}> + + + !openValue && close()} open={editing !== null} diff --git a/dashboard/src/routes/monitoring/ConversationPage.tsx b/dashboard/src/routes/monitoring/ConversationPage.tsx index 12f3bcb3..9459c902 100644 --- a/dashboard/src/routes/monitoring/ConversationPage.tsx +++ b/dashboard/src/routes/monitoring/ConversationPage.tsx @@ -21,6 +21,7 @@ import { DataTable, type DataTableColumn } from '@/components/ui/DataTable'; import { DialogActions } from '@/components/ui/DialogActions'; import { Pagination } from '@/components/ui/Pagination'; import { SearchField } from '@/components/ui/SearchField'; +import { SelectControl } from '@/components/ui/SelectControl'; import { confirmDestructiveAction } from '@/components/ui/confirm'; import { toast } from '@/stores/feedback'; import { @@ -305,7 +306,7 @@ export default function ConversationPage() { placeholder={t(`${prefix}.filters.platform`)} value={platform} /> - { setMessageType(event.target.value); setPage(1); @@ -315,7 +316,7 @@ export default function ConversationPage() { {t(`${prefix}.filters.type`)} {t(`${prefix}.messageTypes.group`)} {t(`${prefix}.messageTypes.friend`)} - + { diff --git a/dashboard/src/routes/monitoring/SessionManagementControls.tsx b/dashboard/src/routes/monitoring/SessionManagementControls.tsx index 84060afb..f3e5ad90 100644 --- a/dashboard/src/routes/monitoring/SessionManagementControls.tsx +++ b/dashboard/src/routes/monitoring/SessionManagementControls.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { MdiIcon } from '@/components/icons/MdiIcon'; +import { SelectControl } from '@/components/ui/SelectControl'; import { FOLLOW_CONFIG_VALUE, sessionDisplayName, type ProviderOption, type UmoInfo } from './sessionManagementModel'; export function UmoDisplay({ @@ -82,14 +83,14 @@ export function ProviderSelect({ return ( {label} - onChange(event.target.value)} value={value}> + onChange(event.target.value)} value={value}> {followText} {options.map((provider) => ( {provider.model ? `${provider.name || provider.id} (${provider.model})` : provider.name || provider.id} ))} - + ); } diff --git a/dashboard/src/routes/monitoring/SessionManagementPage.test.tsx b/dashboard/src/routes/monitoring/SessionManagementPage.test.tsx index 645b6791..99189ffc 100644 --- a/dashboard/src/routes/monitoring/SessionManagementPage.test.tsx +++ b/dashboard/src/routes/monitoring/SessionManagementPage.test.tsx @@ -71,10 +71,8 @@ describe('SessionManagementPage', () => { await screen.findByText('user-1'); const checkboxes = screen.getAllByRole('checkbox'); await user.click(checkboxes[1]); - await user.selectOptions( - screen.getByRole('combobox', { name: 'features.session-management.batchOperations.llmStatus' }), - 'true', - ); + await user.click(screen.getByRole('button', { name: 'features.session-management.batchOperations.llmStatus' })); + await user.click(screen.getByRole('option', { name: 'features.session-management.status.enabled' })); await user.click(screen.getByRole('button', { name: 'features.session-management.batchOperations.apply' })); await waitFor(() => diff --git a/dashboard/src/routes/monitoring/SessionManagementPage.tsx b/dashboard/src/routes/monitoring/SessionManagementPage.tsx index fa464ea2..c9ed7847 100644 --- a/dashboard/src/routes/monitoring/SessionManagementPage.tsx +++ b/dashboard/src/routes/monitoring/SessionManagementPage.tsx @@ -18,6 +18,7 @@ import { externalLinks } from '@/config/links'; import { paginationDefaults } from '@/config/defaults'; import { Dialog, DialogClose } from '@/components/headless/Dialog'; import { MdiIcon } from '@/components/icons/MdiIcon'; +import { SelectControl } from '@/components/ui/SelectControl'; import { confirmAction, toast } from '@/stores/feedback'; import { EditorSection, MultiSelect, ProviderSelect, TransferList, UmoDisplay } from './SessionManagementControls'; import { @@ -626,7 +627,7 @@ export default function SessionManagementPage() {
{t('createDialog.step1Hint')}
{e('empty.noPluginsDesc')}