feat: complete tool and model restart feedback

This commit is contained in:
SiYue-ZO 2026-04-25 02:03:26 +08:00
parent 02d9a0d190
commit cbe6a0907c
5 changed files with 71 additions and 13 deletions

View file

@ -1,5 +1,6 @@
import { useLayoutEffect, useRef } from "react" import { useLayoutEffect, useRef } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { ToolLibraryTab } from "./tool-library-tab" import { ToolLibraryTab } from "./tool-library-tab"
@ -26,6 +27,7 @@ export function ToolsPage() {
isToolsLoading, isToolsLoading,
isWebSearchLoading, isWebSearchLoading,
isWebSearchSaving, isWebSearchSaving,
isWebSearchDirty,
setActiveTab, setActiveTab,
setSearchQuery, setSearchQuery,
setStatusFilter, setStatusFilter,
@ -72,6 +74,7 @@ export function ToolsPage() {
isLoading={isWebSearchLoading} isLoading={isWebSearchLoading}
hasError={hasWebSearchError} hasError={hasWebSearchError}
isSaving={isWebSearchSaving} isSaving={isWebSearchSaving}
isDirty={isWebSearchDirty}
onSave={saveWebSearchConfig} onSave={saveWebSearchConfig}
onToggleProviderExpand={toggleExpandedProvider} onToggleProviderExpand={toggleExpandedProvider}
onUpdateDraft={updateWebSearchDraft} onUpdateDraft={updateWebSearchDraft}

View file

@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
type WebSearchConfigResponse,
getTools, getTools,
getWebSearchConfig, getWebSearchConfig,
setToolEnabled, setToolEnabled,
updateWebSearchConfig, updateWebSearchConfig,
type WebSearchConfigResponse,
} from "@/api/tools" } from "@/api/tools"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway" import { refreshGatewayState } from "@/store/gateway"
import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types" import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types"
@ -35,24 +36,38 @@ export function useToolsPage() {
queryFn: getWebSearchConfig, queryFn: getWebSearchConfig,
}) })
const tools = useMemo(() => toolsQuery.data?.tools ?? [], [toolsQuery.data?.tools]) const tools = useMemo(
() => toolsQuery.data?.tools ?? [],
[toolsQuery.data?.tools],
)
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase() const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null
const isWebSearchDirty = useMemo(() => {
if (!webSearchDraft || !webSearchQuery.data) {
return false
}
return (
JSON.stringify(webSearchDraft) !== JSON.stringify(webSearchQuery.data)
)
}, [webSearchDraft, webSearchQuery.data])
const toggleToolMutation = useMutation({ const toggleToolMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled), setToolEnabled(name, enabled),
onSuccess: (_, variables) => { onSuccess: async (_, variables) => {
toast.success( const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
variables.enabled variables.enabled
? t("pages.agent.tools.enable_success", "Tool enabled successfully") ? t("pages.agent.tools.enable_success", "Tool enabled successfully")
: t( : t(
"pages.agent.tools.disable_success", "pages.agent.tools.disable_success",
"Tool disabled successfully", "Tool disabled successfully",
), ),
t("navigation.tools", "Tools"),
gateway?.restartRequired === true,
) )
void queryClient.invalidateQueries({ queryKey: ["tools"] }) void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
}, },
onError: (error) => { onError: (error) => {
toast.error( toast.error(
@ -65,20 +80,23 @@ export function useToolsPage() {
const saveWebSearchMutation = useMutation({ const saveWebSearchMutation = useMutation({
mutationFn: updateWebSearchConfig, mutationFn: updateWebSearchConfig,
onSuccess: (updatedConfig) => { onSuccess: async (updatedConfig) => {
queryClient.setQueryData(["tools", "web-search-config"], updatedConfig) queryClient.setQueryData(["tools", "web-search-config"], updatedConfig)
setWebSearchDraftOverride(null) setWebSearchDraftOverride(null)
toast.success( const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
t( t(
"pages.agent.tools.web_search.save_success", "pages.agent.tools.web_search.save_success",
"Settings saved successfully", "Settings saved successfully",
), ),
t("pages.agent.tools.web_search.title", "Web Search Configuration"),
gateway?.restartRequired === true,
) )
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: ["tools", "web-search-config"], queryKey: ["tools", "web-search-config"],
}) })
void queryClient.invalidateQueries({ queryKey: ["tools"] }) void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
}, },
onError: (error) => { onError: (error) => {
toast.error( toast.error(
@ -105,7 +123,9 @@ export function useToolsPage() {
} }
if (normalizedSearchQuery) { if (normalizedSearchQuery) {
const matchesName = tool.name.toLowerCase().includes(normalizedSearchQuery) const matchesName = tool.name
.toLowerCase()
.includes(normalizedSearchQuery)
const matchesDescription = (tool.description || "") const matchesDescription = (tool.description || "")
.toLowerCase() .toLowerCase()
.includes(normalizedSearchQuery) .includes(normalizedSearchQuery)
@ -177,6 +197,7 @@ export function useToolsPage() {
isToolsLoading: toolsQuery.isLoading, isToolsLoading: toolsQuery.isLoading,
isWebSearchLoading: webSearchQuery.isLoading, isWebSearchLoading: webSearchQuery.isLoading,
isWebSearchSaving: saveWebSearchMutation.isPending, isWebSearchSaving: saveWebSearchMutation.isPending,
isWebSearchDirty,
setActiveTab, setActiveTab,
setSearchQuery, setSearchQuery,
setStatusFilter, setStatusFilter,

View file

@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import type { WebSearchConfigResponse } from "@/api/tools" import type { WebSearchConfigResponse } from "@/api/tools"
import { ConfigChangeNotice } from "@/components/config-change-notice"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
@ -15,6 +16,7 @@ interface WebSearchTabProps {
isLoading: boolean isLoading: boolean
hasError: boolean hasError: boolean
isSaving: boolean isSaving: boolean
isDirty: boolean
onSave: () => void onSave: () => void
onToggleProviderExpand: (providerId: string) => void onToggleProviderExpand: (providerId: string) => void
onUpdateDraft: WebSearchDraftUpdater onUpdateDraft: WebSearchDraftUpdater
@ -27,6 +29,7 @@ export function WebSearchTab({
isLoading, isLoading,
hasError, hasError,
isSaving, isSaving,
isDirty,
onSave, onSave,
onToggleProviderExpand, onToggleProviderExpand,
onUpdateDraft, onUpdateDraft,
@ -66,13 +69,21 @@ export function WebSearchTab({
<Button <Button
onClick={onSave} onClick={onSave}
disabled={isSaving} disabled={!isDirty || isSaving}
className="h-10 shrink-0 rounded-xl px-6 shadow-sm transition-all active:scale-95" className="h-10 shrink-0 rounded-xl px-6 shadow-sm transition-all active:scale-95"
> >
{t("pages.agent.tools.web_search.save", "Save Changes")} {t("pages.agent.tools.web_search.save", "Save Changes")}
</Button> </Button>
</div> </div>
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("pages.agent.tools.web_search.unsaved_prompt")}
/>
)}
<div className="space-y-10"> <div className="space-y-10">
<WebSearchGeneralSettings <WebSearchGeneralSettings
draft={draft} draft={draft}

View file

@ -1,10 +1,13 @@
import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react" import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
import { PageHeader } from "@/components/page-header" import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
import { AddModelSheet } from "./add-model-sheet" import { AddModelSheet } from "./add-model-sheet"
import { DeleteModelDialog } from "./delete-model-dialog" import { DeleteModelDialog } from "./delete-model-dialog"
@ -95,8 +98,15 @@ export function ModelsPage() {
try { try {
await setDefaultModel(model.model_name) await setDefaultModel(model.model_name)
await fetchModels() await fetchModels()
} catch { const gateway = await refreshGatewayState({ force: true })
// ignore showSaveSuccessOrRestartToast(
t,
t("models.defaultChangeSuccess"),
model.model_name,
gateway?.restartRequired === true,
)
} catch (e) {
toast.error(e instanceof Error ? e.message : t("models.loadError"))
} finally { } finally {
setSettingDefaultIndex(null) setSettingDefaultIndex(null)
} }

View file

@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models" import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
interface UseChatModelsOptions { interface UseChatModelsOptions {
isConnected: boolean isConnected: boolean
@ -18,6 +22,7 @@ function isLocalModel(model: ModelInfo): boolean {
} }
export function useChatModels({ isConnected }: UseChatModelsOptions) { export function useChatModels({ isConnected }: UseChatModelsOptions) {
const { t } = useTranslation()
const [modelList, setModelList] = useState<ModelInfo[]>([]) const [modelList, setModelList] = useState<ModelInfo[]>([])
const [defaultModelName, setDefaultModelName] = useState("") const [defaultModelName, setDefaultModelName] = useState("")
const setDefaultRequestIdRef = useRef(0) const setDefaultRequestIdRef = useRef(0)
@ -58,11 +63,19 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
if (data.models.some((m) => m.model_name === data.default_model)) { if (data.models.some((m) => m.model_name === data.default_model)) {
setDefaultModelName(data.default_model) setDefaultModelName(data.default_model)
} }
const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
t("models.defaultChangeSuccess"),
modelName,
gateway?.restartRequired === true,
)
} catch (err) { } catch (err) {
console.error("Failed to set default model:", err) console.error("Failed to set default model:", err)
toast.error(err instanceof Error ? err.message : t("models.loadError"))
} }
}, },
[defaultModelName], [defaultModelName, t],
) )
const hasAvailableModels = useMemo( const hasAvailableModels = useMemo(