feat: add config save and restart prompts

This commit is contained in:
SiYue-ZO
2026-04-25 02:03:00 +08:00
parent 39dec35408
commit afc600baed
10 changed files with 257 additions and 48 deletions
@@ -28,10 +28,12 @@ import { SlackForm } from "@/components/channels/channel-forms/slack-form"
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
import { WecomForm } from "@/components/channels/channel-forms/wecom-form"
import { WeixinForm } from "@/components/channels/channel-forms/weixin-form"
import { ConfigChangeNotice } from "@/components/config-change-notice"
import { PageHeader } from "@/components/page-header"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { useGateway } from "@/hooks/use-gateway"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
interface ChannelConfigPageProps {
@@ -296,21 +298,34 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
const [enabled, setEnabled] = useState(false)
const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0)
const arrayFieldFlushersRef = useRef(new Map<string, ArrayFieldFlusher>())
const loadRequestIdRef = useRef(0)
const resetPageState = useCallback(() => {
arrayFieldFlushersRef.current.clear()
setChannel(null)
setBaseConfig({})
setEditConfig({})
setConfiguredSecrets([])
setEnabled(false)
setFetchError("")
setServerError("")
setFieldErrors({})
setArrayFieldResetVersion((version) => version + 1)
}, [])
const loadData = useCallback(
async (silent = false) => {
const requestId = loadRequestIdRef.current + 1
loadRequestIdRef.current = requestId
if (!silent) setLoading(true)
try {
const catalog = await getChannelsCatalog()
if (loadRequestIdRef.current !== requestId) return
const matched =
catalog.channels.find((item) => item.name === channelName) ?? null
if (!matched) {
setChannel(null)
setBaseConfig({})
setEditConfig({})
setConfiguredSecrets([])
setEnabled(false)
resetPageState()
setFetchError(
t("channels.page.notFound", {
name: channelName,
@@ -320,6 +335,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
}
const channelConfig = await getChannelConfig(channelName)
if (loadRequestIdRef.current !== requestId) return
const raw = asRecord(channelConfig.config)
const normalized = normalizeConfig(matched, raw)
@@ -332,18 +348,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
setServerError("")
setFieldErrors({})
} catch (e) {
if (loadRequestIdRef.current !== requestId) return
setConfiguredSecrets([])
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
} finally {
if (!silent) setLoading(false)
if (!silent && loadRequestIdRef.current === requestId) {
setLoading(false)
}
}
},
[channelName, t],
[channelName, resetPageState, t],
)
useEffect(() => {
resetPageState()
setLoading(true)
loadData()
}, [loadData])
}, [loadData, resetPageState])
const previousGatewayStatusRef = useRef(gatewayState)
useEffect(() => {
@@ -359,6 +380,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
return isConfigured(channel, editConfig, configuredSecrets)
}, [channel, configuredSecrets, editConfig])
const isDirty = useMemo(() => {
if (loading || !channel || channel.name !== channelName) return false
const basePayload = buildSavePayload(
channel,
buildEditConfig(channel.name, baseConfig),
asBool(baseConfig.enabled),
)
const currentPayload = buildSavePayload(channel, editConfig, enabled)
return JSON.stringify(basePayload) !== JSON.stringify(currentPayload)
}, [baseConfig, channel, channelName, editConfig, enabled, loading])
const docsUrl = useMemo(() => {
if (!channel) return ""
if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return ""
@@ -479,6 +511,13 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
},
})
await loadData()
const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
t("channels.page.saveSuccess"),
channelDisplayName,
gateway?.restartRequired === true,
)
} catch (e) {
const message =
e instanceof Error ? e.message : t("channels.page.saveError")
@@ -674,11 +713,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
<p className="text-destructive text-sm">{serverError}</p>
)}
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("channels.page.savePrompt")}
/>
)}
<div className="border-border/60 flex justify-end gap-2 border-t py-4">
<Button variant="outline" onClick={handleReset} disabled={saving}>
<Button
variant="outline"
onClick={handleReset}
disabled={!isDirty || saving}
>
{t("common.reset")}
</Button>
<Button onClick={handleSave} disabled={saving}>
<Button onClick={handleSave} disabled={!isDirty || saving}>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
@@ -0,0 +1,48 @@
import {
IconAlertCircle,
IconDeviceFloppy,
IconRefresh,
} from "@tabler/icons-react"
import { cn } from "@/lib/utils"
interface ConfigChangeNoticeProps {
kind: "save" | "restart"
title: string
description?: string
className?: string
}
export function ConfigChangeNotice({
kind,
title,
description,
className,
}: ConfigChangeNoticeProps) {
const Icon =
kind === "restart"
? IconRefresh
: kind === "save"
? IconDeviceFloppy
: IconAlertCircle
return (
<div
className={cn(
"flex items-start gap-3 rounded-lg border px-3 py-2 text-sm",
kind === "restart"
? "border-amber-200 bg-amber-50 text-amber-900"
: "border-yellow-200 bg-yellow-50 text-yellow-900",
className,
)}
>
<Icon className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0">
<p className="font-medium">{title}</p>
{description && (
<p className="mt-0.5 text-xs/5 opacity-85">{description}</p>
)}
</div>
</div>
)
}
@@ -15,6 +15,7 @@ import {
setAutoStartEnabled as updateAutoStartEnabled,
setLauncherConfig as updateLauncherConfig,
} from "@/api/system"
import { ConfigChangeNotice } from "@/components/config-change-notice"
import {
AgentDefaultsSection,
CronSection,
@@ -36,6 +37,7 @@ import {
import { PageHeader } from "@/components/page-header"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
export function ConfigPage() {
@@ -334,8 +336,13 @@ export function ConfigPage() {
queryClient.setQueryData(["system", "autostart"], status)
}
toast.success(t("pages.config.save_success"))
void refreshGatewayState({ force: true })
const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
t("pages.config.save_success"),
t("navigation.config"),
gateway?.restartRequired === true,
)
} catch (err) {
toast.error(
err instanceof Error ? err.message : t("pages.config.save_error"),
@@ -433,8 +440,12 @@ export function ConfigPage() {
{isDirty && (
<div className="border-border/70 bg-background/95 supports-backdrop-filter:bg-background/80 shrink-0 border-t px-3 py-3 shadow-[0_-12px_30px_rgba(15,23,42,0.10)] backdrop-blur lg:px-6">
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="text-muted-foreground/70 text-xs">
{t("pages.config.unsaved_changes")}
<div className="flex-1">
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("pages.config.unsaved_changes")}
/>
</div>
{actionButtons}
</div>
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { launcherFetch } from "@/api/http"
import { ConfigChangeNotice } from "@/components/config-change-notice"
import { PageHeader } from "@/components/page-header"
import {
AlertDialog,
@@ -20,6 +21,7 @@ import {
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
export function RawConfigPage() {
@@ -49,7 +51,6 @@ export function RawConfigPage() {
}
},
onSuccess: (_, submittedConfig) => {
toast.success(t("pages.config.save_success"))
try {
const savedConfig = JSON.parse(submittedConfig)
setLastSavedConfig(savedConfig)
@@ -58,7 +59,14 @@ export function RawConfigPage() {
} catch {
queryClient.invalidateQueries({ queryKey: ["config"] })
}
void refreshGatewayState({ force: true })
void refreshGatewayState({ force: true }).then((gateway) => {
showSaveSuccessOrRestartToast(
t,
t("pages.config.save_success"),
t("navigation.config"),
gateway?.restartRequired === true,
)
})
},
onError: () => {
toast.error(t("pages.config.save_error"))
@@ -141,9 +149,12 @@ export function RawConfigPage() {
) : (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{isDirty && (
<div className="shrink-0 rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
{t("pages.config.unsaved_changes")}
</div>
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("pages.config.unsaved_changes")}
className="shrink-0"
/>
)}
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border shadow-sm">
<Textarea
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { addModel, setDefaultModel } from "@/api/models"
import { ConfigChangeNotice } from "@/components/config-change-notice"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import {
AdvancedSection,
@@ -21,6 +22,8 @@ import {
SheetTitle,
} from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
interface AddForm {
modelName: string
@@ -83,6 +86,8 @@ export function AddModelSheet({
form.apiKey,
t("models.field.apiKeyPlaceholder"),
)
const isDirty =
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
useEffect(() => {
if (open) {
@@ -149,6 +154,13 @@ export function AddModelSheet({
if (setAsDefault) {
await setDefaultModel(modelName)
}
const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
t("models.add.saveSuccess"),
modelName,
gateway?.restartRequired === true,
)
onSaved()
onClose()
} catch (e) {
@@ -367,10 +379,17 @@ export function AddModelSheet({
</div>
<SheetFooter className="border-t-muted border-t px-6 py-4">
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("models.unsavedPrompt")}
/>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
<Button onClick={handleSave} disabled={!isDirty || saving}>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("models.add.confirm")}
</Button>
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models"
import { ConfigChangeNotice } from "@/components/config-change-notice"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import {
AdvancedSection,
@@ -21,6 +22,8 @@ import {
SheetTitle,
} from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
interface EditForm {
provider: string
@@ -46,6 +49,29 @@ interface EditModelSheetProps {
onSaved: () => void
}
function buildInitialEditForm(model: ModelInfo): EditForm {
return {
provider: model.provider ?? "",
modelId: model.model,
apiKey: "",
apiBase: model.api_base ?? "",
proxy: model.proxy ?? "",
authMethod: model.auth_method ?? "",
connectMode: model.connect_mode ?? "",
workspace: model.workspace ?? "",
rpm: model.rpm ? String(model.rpm) : "",
maxTokensField: model.max_tokens_field ?? "",
requestTimeout: model.request_timeout ? String(model.request_timeout) : "",
thinkingLevel: model.thinking_level ?? "",
extraBody: model.extra_body
? JSON.stringify(model.extra_body, null, 2)
: "",
customHeaders: model.custom_headers
? JSON.stringify(model.custom_headers, null, 2)
: "",
}
}
export function EditModelSheet({
model,
open,
@@ -72,31 +98,15 @@ export function EditModelSheet({
const [saving, setSaving] = useState(false)
const [setAsDefault, setSetAsDefault] = useState(false)
const [error, setError] = useState("")
const initialForm = model ? buildInitialEditForm(model) : null
const isDirty =
model != null &&
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
setAsDefault !== model.is_default)
useEffect(() => {
if (model) {
setForm({
provider: model.provider ?? "",
modelId: model.model,
apiKey: "",
apiBase: model.api_base ?? "",
proxy: model.proxy ?? "",
authMethod: model.auth_method ?? "",
connectMode: model.connect_mode ?? "",
workspace: model.workspace ?? "",
rpm: model.rpm ? String(model.rpm) : "",
maxTokensField: model.max_tokens_field ?? "",
requestTimeout: model.request_timeout
? String(model.request_timeout)
: "",
thinkingLevel: model.thinking_level ?? "",
extraBody: model.extra_body
? JSON.stringify(model.extra_body, null, 2)
: "",
customHeaders: model.custom_headers
? JSON.stringify(model.custom_headers, null, 2)
: "",
})
setForm(buildInitialEditForm(model))
setSetAsDefault(model.is_default)
setError("")
}
@@ -142,6 +152,13 @@ export function EditModelSheet({
if (setAsDefault && !model.is_default) {
await setDefaultModel(model.model_name)
}
const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
t("models.edit.saveSuccess"),
model.model_name,
gateway?.restartRequired === true,
)
onSaved()
onClose()
} catch (e) {
@@ -359,10 +376,17 @@ export function EditModelSheet({
</div>
<SheetFooter className="border-t-muted border-t px-6 py-4">
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("models.unsavedPrompt")}
/>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={saving}>
<Button onClick={handleSave} disabled={!isDirty || saving}>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("common.save")}
</Button>