mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat: add config save and restart prompts
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -132,7 +132,10 @@
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"reset": "Reset",
|
||||
"confirm": "Confirm"
|
||||
"confirm": "Confirm",
|
||||
"saveChangesTitle": "You have unsaved configuration changes",
|
||||
"restartRequiredTitle": "Gateway restart required",
|
||||
"restartRequiredDesc": "The latest {{name}} configuration has been saved. Restart the gateway for it to take effect."
|
||||
},
|
||||
"labels": {
|
||||
"loading": "Loading..."
|
||||
@@ -210,6 +213,9 @@
|
||||
},
|
||||
"models": {
|
||||
"description": "Configure API keys for AI providers. Only configured models are available for chat.",
|
||||
"defaultChangeSuccess": "Default model updated.",
|
||||
"unsavedPrompt": "This change has not been saved yet. Save to write it into the model configuration.",
|
||||
"restartHint": "Model configuration changes take effect after the gateway restarts.",
|
||||
"loadError": "Failed to load models",
|
||||
"noDefaultHintPrefix": "No default model set yet. Click",
|
||||
"noDefaultHintSuffix": "to set one.",
|
||||
@@ -253,6 +259,7 @@
|
||||
"errorRequired": "This field is required.",
|
||||
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
||||
"saveError": "Failed to add model",
|
||||
"saveSuccess": "Model added.",
|
||||
"confirm": "Add Model"
|
||||
},
|
||||
"delete": {
|
||||
@@ -296,7 +303,8 @@
|
||||
"title": "Configure {{name}}",
|
||||
"apiKeyHint": "A key is already set. Leave blank to keep it unchanged.",
|
||||
"oauthNote": "This provider uses OAuth — no API key required.",
|
||||
"saveError": "Failed to save"
|
||||
"saveError": "Failed to save",
|
||||
"saveSuccess": "Model configuration saved."
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
@@ -382,6 +390,7 @@
|
||||
"notFound": "Channel \"{{name}}\" is not supported.",
|
||||
"saveSuccess": "Channel configuration saved.",
|
||||
"saveError": "Failed to save channel configuration",
|
||||
"savePrompt": "This change has not been saved yet. Save to write it into the channel configuration.",
|
||||
"docLink": "Documentation",
|
||||
"enableLabel": "Enable channel",
|
||||
"restartRequiredTitle": "Gateway restart required",
|
||||
@@ -555,6 +564,7 @@
|
||||
"web_search": {
|
||||
"title": "Web Search",
|
||||
"description": "Provide web search capability for agents to find the latest real-world info. Automatically routes to the optimal active provider.",
|
||||
"unsaved_prompt": "This change has not been saved yet. Save to write it into the Web Search configuration.",
|
||||
"global_settings": "General",
|
||||
"providers_config": "Integrations",
|
||||
"load_error": "Failed to load web search configuration.",
|
||||
@@ -673,6 +683,7 @@
|
||||
"server_port": "Service Port",
|
||||
"server_port_hint": "HTTP port used by PicoClaw Web.",
|
||||
"launcher_section_hint": "Changes in this section take effect after the launcher restarts.",
|
||||
"gateway_restart_hint": "Changes in this section take effect after the gateway restarts.",
|
||||
"dashboard_password": "Login Password",
|
||||
"dashboard_password_hint": "Set a new login password.",
|
||||
"dashboard_password_placeholder": "At least 8 characters",
|
||||
|
||||
@@ -132,7 +132,10 @@
|
||||
"save": "保存",
|
||||
"saving": "保存中...",
|
||||
"reset": "重置",
|
||||
"confirm": "确认"
|
||||
"confirm": "确认",
|
||||
"saveChangesTitle": "有未保存的配置更改",
|
||||
"restartRequiredTitle": "需要重启服务",
|
||||
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。"
|
||||
},
|
||||
"labels": {
|
||||
"loading": "加载中..."
|
||||
@@ -210,6 +213,9 @@
|
||||
},
|
||||
"models": {
|
||||
"description": "为 AI 服务商配置 API Key。只有已配置的模型可用于对话。",
|
||||
"defaultChangeSuccess": "默认模型已更新。",
|
||||
"unsavedPrompt": "当前修改尚未保存,保存后才会写入模型配置。",
|
||||
"restartHint": "模型配置保存后需要重启服务才能生效。",
|
||||
"loadError": "加载模型列表失败",
|
||||
"noDefaultHintPrefix": "尚未设置默认模型,点击",
|
||||
"noDefaultHintSuffix": "设为默认。",
|
||||
@@ -253,6 +259,7 @@
|
||||
"errorRequired": "此字段为必填项。",
|
||||
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
||||
"saveError": "添加模型失败",
|
||||
"saveSuccess": "模型已添加。",
|
||||
"confirm": "添加模型"
|
||||
},
|
||||
"delete": {
|
||||
@@ -296,7 +303,8 @@
|
||||
"title": "配置 {{name}}",
|
||||
"apiKeyHint": "已设置 API Key,留空表示不修改。",
|
||||
"oauthNote": "该服务商使用 OAuth 认证,无需 API Key。",
|
||||
"saveError": "保存失败"
|
||||
"saveError": "保存失败",
|
||||
"saveSuccess": "模型配置已保存。"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
@@ -382,6 +390,7 @@
|
||||
"notFound": "不支持频道“{{name}}”。",
|
||||
"saveSuccess": "频道配置已保存。",
|
||||
"saveError": "保存频道配置失败",
|
||||
"savePrompt": "当前修改尚未保存,点击保存后才会写入频道配置。",
|
||||
"docLink": "配置文档",
|
||||
"enableLabel": "启用频道",
|
||||
"restartRequiredTitle": "需要重启服务",
|
||||
@@ -555,6 +564,7 @@
|
||||
"web_search": {
|
||||
"title": "网页搜索",
|
||||
"description": "为智能体提供网页搜索能力。自动路由到当前处于激活状态的最佳服务。",
|
||||
"unsaved_prompt": "当前修改尚未保存,点击保存后才会写入 Web Search 配置。",
|
||||
"global_settings": "常规",
|
||||
"providers_config": "集成",
|
||||
"load_error": "加载 Web Search 配置失败。",
|
||||
@@ -673,6 +683,7 @@
|
||||
"server_port": "服务端口",
|
||||
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
|
||||
"launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效",
|
||||
"gateway_restart_hint": "这组配置保存后,需要重启服务才能正式生效。",
|
||||
"dashboard_password": "登录密码",
|
||||
"dashboard_password_hint": "设置新的登录密码",
|
||||
"dashboard_password_placeholder": "至少 8 个字符",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TFunction } from "i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function showRestartRequiredToast(t: TFunction, name: string) {
|
||||
toast.warning(t("common.restartRequiredTitle"), {
|
||||
description: t("common.restartRequiredDesc", { name }),
|
||||
})
|
||||
}
|
||||
|
||||
export function showSaveSuccessOrRestartToast(
|
||||
t: TFunction,
|
||||
savedMessage: string,
|
||||
name: string,
|
||||
restartRequired: boolean,
|
||||
) {
|
||||
if (restartRequired) {
|
||||
showRestartRequiredToast(t, name)
|
||||
return
|
||||
}
|
||||
toast.success(savedMessage)
|
||||
}
|
||||
@@ -172,7 +172,7 @@ export async function refreshGatewayState(
|
||||
if (options.force) {
|
||||
return refreshGatewayState()
|
||||
}
|
||||
return
|
||||
return getDefaultStore().get(gatewayAtom)
|
||||
}
|
||||
|
||||
gatewayPollingRequest = (async () => {
|
||||
@@ -195,6 +195,8 @@ export async function refreshGatewayState(
|
||||
gatewayPollingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
return getDefaultStore().get(gatewayAtom)
|
||||
}
|
||||
|
||||
export function subscribeGatewayPolling() {
|
||||
|
||||
Reference in New Issue
Block a user