Merge pull request #2663 from SiYue-ZO/feature/config-save-restart-prompts

feat: improve config save and restart feedback
This commit is contained in:
美電球 2026-04-27 15:45:38 +08:00 committed by GitHub
commit f90e756e21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 691 additions and 61 deletions

View file

@ -2,6 +2,7 @@ package api
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
@ -10,7 +11,9 @@ import (
"net/http"
"os"
"os/exec"
"reflect"
"runtime"
"sort"
"strconv"
"strings"
"sync"
@ -431,6 +434,10 @@ func computeConfigSignature(cfg *config.Config) string {
}
if cfg.Tools.Web.Enabled {
toolSignatures = append(toolSignatures, "web")
webConfig, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(cfg.Tools.Web)))
if err == nil {
parts = append(parts, "webcfg:"+string(webConfig))
}
}
if cfg.Tools.WebFetch.Enabled {
toolSignatures = append(toolSignatures, "web_fetch")
@ -474,9 +481,175 @@ func computeConfigSignature(cfg *config.Config) string {
if len(toolSignatures) > 0 {
parts = append(parts, "tools:"+strings.Join(toolSignatures, ","))
}
channelSignatures := computeChannelSignatures(cfg.Channels)
if len(channelSignatures) > 0 {
parts = append(parts, "channels:"+strings.Join(channelSignatures, ","))
}
return strings.Join(parts, ";")
}
func computeChannelSignatures(channels config.ChannelsConfig) []string {
if len(channels) == 0 {
return nil
}
keys := make([]string, 0, len(channels))
for name := range channels {
keys = append(keys, name)
}
sort.Strings(keys)
signatures := make([]string, 0, len(keys))
for _, name := range keys {
channel := channels[name]
if channel == nil {
signatures = append(signatures, name+":<nil>")
continue
}
payload := struct {
Enabled bool `json:"enabled"`
Type string `json:"type"`
AllowFrom config.FlexibleStringSlice `json:"allow_from,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id,omitempty"`
GroupTrigger config.GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing config.TypingConfig `json:"typing,omitempty"`
Placeholder config.PlaceholderConfig `json:"placeholder,omitempty"`
Settings json.RawMessage `json:"settings,omitempty"`
}{
Enabled: channel.Enabled,
Type: channel.Type,
AllowFrom: channel.AllowFrom,
ReasoningChannelID: channel.ReasoningChannelID,
GroupTrigger: channel.GroupTrigger,
Typing: channel.Typing,
Placeholder: channel.Placeholder,
Settings: normalizeChannelSettings(channel),
}
encoded, err := json.Marshal(payload)
if err != nil {
signatures = append(signatures, name+":<invalid>")
continue
}
signatures = append(signatures, name+":"+string(encoded))
}
return signatures
}
func normalizeChannelSettings(channel *config.Channel) json.RawMessage {
if channel == nil {
return nil
}
decoded, err := channel.GetDecoded()
if err == nil && decoded != nil {
normalized, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(decoded)))
if err == nil {
return normalized
}
}
return normalizeRawJSON(channel.Settings)
}
func normalizeRawJSON(raw config.RawNode) json.RawMessage {
if len(raw) == 0 {
return nil
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return bytes.TrimSpace(raw)
}
normalized, err := json.Marshal(value)
if err != nil {
return bytes.TrimSpace(raw)
}
return normalized
}
func canonicalizeSignatureValue(value reflect.Value) any {
if !value.IsValid() {
return nil
}
if value.CanInterface() {
switch typed := value.Interface().(type) {
case config.SecureString:
return typed.String()
case *config.SecureString:
if typed == nil {
return ""
}
return typed.String()
case config.SecureStrings:
return typed.Values()
case *config.SecureStrings:
if typed == nil {
return nil
}
return typed.Values()
}
}
switch value.Kind() {
case reflect.Interface, reflect.Pointer:
if value.IsNil() {
return nil
}
return canonicalizeSignatureValue(value.Elem())
case reflect.Struct:
result := make(map[string]any)
valueType := value.Type()
for i := 0; i < value.NumField(); i++ {
field := valueType.Field(i)
if field.PkgPath != "" {
continue
}
tag := field.Tag.Get("json")
name := field.Name
if tag != "" {
if comma := strings.Index(tag, ","); comma >= 0 {
tag = tag[:comma]
}
if tag == "-" {
continue
}
if tag != "" {
name = tag
}
}
result[name] = canonicalizeSignatureValue(value.Field(i))
}
return result
case reflect.Slice, reflect.Array:
length := value.Len()
result := make([]any, 0, length)
for i := 0; i < length; i++ {
result = append(result, canonicalizeSignatureValue(value.Index(i)))
}
return result
case reflect.Map:
if value.Type().Key().Kind() != reflect.String {
return value.Interface()
}
result := make(map[string]any, value.Len())
iter := value.MapRange()
for iter.Next() {
result[iter.Key().String()] = canonicalizeSignatureValue(iter.Value())
}
return result
default:
if value.CanInterface() {
return value.Interface()
}
return nil
}
}
func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool {
if gatewayStatus != "running" {
return false
@ -742,6 +915,11 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
// Already holding gateway.mu from caller.
if changed {
refreshPicoTokensLocked(h.configPath)
cfg, err = config.LoadConfig(h.configPath)
if err != nil {
return 0, fmt.Errorf("failed to reload config after ensuring pico channel: %w", err)
}
defaultModelName = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
}
if err := cmd.Start(); err != nil {

View file

@ -286,6 +286,61 @@ func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T)
}
}
func TestStartGatewayLocked_UsesReloadedConfigForBootSignature(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("sleep command differs on Windows")
}
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
delete(cfg.Channels, "pico")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)
gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd {
return exec.Command("sleep", "30")
}
originalSignature := computeConfigSignature(cfg)
pid, err := h.startGatewayLocked("starting", 0)
if err != nil {
t.Fatalf("startGatewayLocked() error = %v", err)
}
if pid <= 0 {
t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid)
}
gateway.mu.Lock()
cmd := gateway.cmd
bootSignature := gateway.bootConfigSignature
gateway.mu.Unlock()
t.Cleanup(func() {
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
if cmd != nil {
_ = cmd.Wait()
}
})
updatedCfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
expectedSignature := computeConfigSignature(updatedCfg)
if expectedSignature == originalSignature {
t.Fatal("expected EnsurePicoChannel() to change the config signature during gateway start")
}
if bootSignature != expectedSignature {
t.Fatalf("bootConfigSignature = %q, want %q", bootSignature, expectedSignature)
}
}
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
@ -1108,6 +1163,136 @@ func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) {
}
}
func TestGatewayStatusRequiresRestartAfterChannelChange(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].SetAPIKey("test-key")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("FindProcess() error = %v", err)
}
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock()
gateway.cmd = &exec.Cmd{Process: process}
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
gateway.bootConfigSignature = bootSignature
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
updatedCfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
telegram := updatedCfg.Channels.Get("telegram")
if telegram == nil {
t.Fatalf("expected default telegram channel config")
}
telegram.Enabled = !telegram.Enabled
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "running" {
t.Fatalf("gateway_status = %#v, want %q", got, "running")
}
if got := body["gateway_restart_required"]; got != true {
t.Fatalf("gateway_restart_required = %#v, want true", got)
}
}
func TestGatewayStatusRequiresRestartAfterWebSearchConfigChange(t *testing.T) {
resetGatewayTestState(t)
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := config.DefaultConfig()
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
cfg.ModelList[0].SetAPIKey("test-key")
cfg.Tools.Web.Enabled = true
cfg.Tools.Web.Provider = "sogou"
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("FindProcess() error = %v", err)
}
bootSignature := computeConfigSignature(cfg)
gateway.mu.Lock()
gateway.cmd = &exec.Cmd{Process: process}
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
gateway.bootConfigSignature = bootSignature
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
updatedCfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
updatedCfg.Tools.Web.Provider = "duckduckgo"
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got := body["gateway_status"]; got != "running" {
t.Fatalf("gateway_status = %#v, want %q", got, "running")
}
if got := body["gateway_restart_required"]; got != true {
t.Fatalf("gateway_restart_required = %#v, want true", got)
}
}
func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) {
resetGatewayTestState(t)

View file

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

View file

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

View file

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

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -1,6 +1,10 @@
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 { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
interface UseChatModelsOptions {
isConnected: boolean
@ -18,6 +22,7 @@ function isLocalModel(model: ModelInfo): boolean {
}
export function useChatModels({ isConnected }: UseChatModelsOptions) {
const { t } = useTranslation()
const [modelList, setModelList] = useState<ModelInfo[]>([])
const [defaultModelName, setDefaultModelName] = useState("")
const setDefaultRequestIdRef = useRef(0)
@ -58,11 +63,19 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
if (data.models.some((m) => m.model_name === data.default_model)) {
setDefaultModelName(data.default_model)
}
const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
t("models.defaultChangeSuccess"),
modelName,
gateway?.restartRequired === true,
)
} catch (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(

View file

@ -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",

View file

@ -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 个字符",

View file

@ -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)
}

View file

@ -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() {