diff --git a/web/app/src/hooks/workspace/useAgentController.ts b/web/app/src/hooks/workspace/useAgentController.ts index 4b7e76c6..07b468e1 100644 --- a/web/app/src/hooks/workspace/useAgentController.ts +++ b/web/app/src/hooks/workspace/useAgentController.ts @@ -36,8 +36,11 @@ import { applyTemplateToDraft, advanceAgentProgress, agentDraftWithRuntimeFieldsFromAgent, + agentPageLLMProfileChanged, agentRuntimePollSettled, agentToDraft, + isAgentProfileDraftComplete, + isAgentProfileMarkedComplete, availableManagerRebuildRuntimeOptions, collectManagerTemplateVariants, defaultManagerRebuildImageForRuntime, @@ -50,6 +53,7 @@ import { isNotificationBotDraftContext, isNotifierRuntimeDraft, isNotifierRuntimeDraftOnAgentPage, + notifierFormIsComplete, mergeAgentIntoList, normalizeAuthProviderName, partitionWorkspaceAgentItems, @@ -259,6 +263,7 @@ export function useAgentController({ const { models: agentPageModels, modelBusy: agentPageModelBusy, + modelError: agentPageModelError, resetModels: resetAgentPageModels, } = useProfileModelOptions({ draft: agentPageDraft, @@ -830,8 +835,8 @@ export function useAgentController({ }); } - async function saveAgentPage(draftOverride?: AgentDraft): Promise { - const draftToSave = draftOverride ?? agentPageDraft; + async function saveAgentPage(): Promise { + const draftToSave = agentPageDraft; if (!draftToSave || !selectedAgentForPage?.id) { return; } @@ -840,6 +845,10 @@ export function useAgentController({ try { const draft = ensureNotifierPullSubscriptionDraft(draftToSave); if (isNotifierRuntimeDraftOnAgentPage(draftToSave, selectedAgentForPage)) { + if (!notifierFormIsComplete(draftToSave, selectedAgentForPage)) { + setAgentPageError(t("profileSaveIncompleteError")); + return; + } const runtimeOptions = draftNotifierRuntimeOptionsForSave(draft, { mergeNotifier: true }); const payload: AgentUpdatePayload = { name: draftToSave.name, @@ -895,12 +904,18 @@ export function useAgentController({ setAgentPageSavedDraft(nextDraft); return; } + const llmProfileChanged = agentPageLLMProfileChanged(draftToSave, agentPageSavedDraft); + if (llmProfileChanged && !isAgentProfileDraftComplete(draftToSave)) { + setAgentPageError(t("profileSaveIncompleteError")); + return; + } debugAgentPageSavePayload("full", payload); const managerBeforeSave = selectedAgentForPage; + const profileIncompleteBeforeSave = !isAgentProfileMarkedComplete(agentPageSavedDraft); const saved = await updateAgentRequest(selectedAgentForPage.id, payload); await refreshAgentsWithUpdatedAgent(saved); if (saved.id === MANAGER_AGENT_ID && profileChanged) { - await syncManagerRuntimeAfterProfileSave(managerBeforeSave); + void syncManagerRuntimeAfterProfileSave(managerBeforeSave, profileIncompleteBeforeSave); } await refreshWorkspaceBootstrap(); if (saved.id === MANAGER_AGENT_ID) { @@ -910,6 +925,15 @@ export function useAgentController({ const savedDraft = await agentDraftFromItem(saved); setAgentPageDraft(savedDraft); setAgentPageSavedDraft(savedDraft); + if ( + profileChanged && + saved.id === MANAGER_AGENT_ID && + !isAgentProfileMarkedComplete(saved) && + !isAgentProfileMarkedComplete(savedDraft) + ) { + setAgentPageError(t("profileSaveIncompleteError")); + showAgentPageNotice(t("profileSetupIncompleteAfterSave")); + } } catch (err) { setAgentPageError(errorMessage(err, t("agentActionFailed"))); } finally { @@ -917,15 +941,6 @@ export function useAgentController({ } } - async function saveAgentPageAvatar(avatar: string): Promise { - if (!agentPageDraft) { - return; - } - const nextDraft = { ...agentPageDraft, avatar }; - setAgentPageDraft(nextDraft); - await saveAgentPage(nextDraft); - } - async function publishAgentPage(): Promise { if (!selectedAgentForPage?.id || agentPagePublishBusy) { return; @@ -1298,6 +1313,7 @@ export function useAgentController({ hasUnsavedChanges: agentPageHasUnsavedChanges, models: agentPageModels, modelBusy: agentPageModelBusy, + modelError: agentPageModelError, saving: agentPageBusy, publishBusy: agentPagePublishBusy, saveError: agentPageError, @@ -1316,7 +1332,6 @@ export function useAgentController({ onSelectWorkspaceFile: setSelectedAgentWorkspacePath, onDraftChange: setAgentPageDraft, onSave: saveAgentPage, - onAvatarSave: saveAgentPageAvatar, onPublish: publishAgentPage, onProviderLogin: loginCLIProxyProvider, onStart: (item: AgentLike | null | undefined) => runAgentAction(item, "start"), diff --git a/web/app/src/models/agents.ts b/web/app/src/models/agents.ts index 4198f276..ae95d3c4 100644 --- a/web/app/src/models/agents.ts +++ b/web/app/src/models/agents.ts @@ -1057,12 +1057,66 @@ export function isAgentProfileDraftComplete(draft: Partial | null | if (!String(draft?.model_id ?? "").trim()) { return false; } - if (draft?.provider === "api" && !String(draft.base_url ?? "").trim()) { - return false; + if (draft?.provider === "api") { + if (!String(draft.base_url ?? "").trim()) { + return false; + } + if (!String(draft.api_key ?? "").trim() && !draft.api_key_set) { + return false; + } } return true; } +export function llmProfilePayloadForCompare(draft: AgentDraft | null | undefined): string { + if (!draft) { + return ""; + } + const normalized = ensureNotifierPullSubscriptionDraft(draft); + const profile = draftToProfile(normalized, { + name: normalized.name, + description: normalized.description, + }); + return JSON.stringify({ + provider: profile.provider, + base_url: profile.base_url, + api_key: profile.api_key, + model_id: profile.model_id, + reasoning_effort: profile.reasoning_effort, + enable_fast_mode: profile.enable_fast_mode, + headers: profile.headers, + request_options: profile.request_options, + env: profile.env, + }); +} + +export function agentPageLLMProfileChanged( + draft: AgentDraft | null | undefined, + savedDraft: AgentDraft | null | undefined, +): boolean { + return llmProfilePayloadForCompare(draft) !== llmProfilePayloadForCompare(savedDraft); +} + +export function agentProfilePageSaveDisabled( + draft: AgentDraft | null | undefined, + item: AgentLike | null | undefined, + options: { saving?: boolean; savedDraft?: AgentDraft | null } = {}, +): boolean { + if (options.saving || !draft) { + return true; + } + if (!String(draft.name ?? "").trim()) { + return true; + } + if (isNotifierRuntimeDraftOnAgentPage(draft, item)) { + return !notifierFormIsComplete(draft, item); + } + if (!agentPageLLMProfileChanged(draft, options.savedDraft ?? null)) { + return false; + } + return !isAgentProfileDraftComplete(draft); +} + export function isAgentIncomplete( item: AgentLike | null | undefined, draftOverride?: AgentDraft | null | undefined, @@ -1110,11 +1164,19 @@ export function agentRuntimePollSettled(item: AgentLike | null | undefined): boo if (isAgentRunning(item)) { return true; } - if (!String(item?.box_id ?? "").trim()) { - return false; - } const status = String(item?.status ?? "").toLowerCase(); - return status === "stopped" || status === "offline" || status === "failed" || status === "error"; + if (status === "stopped" || status === "offline" || status === "failed" || status === "error") { + return true; + } + if ( + isAgentProfileMarkedComplete(item) && + status !== "profile_incomplete" && + status !== "starting" && + status !== "provisioning" + ) { + return true; + } + return false; } export function isAgentUpgradeNeeded(item: AgentLike | null | undefined): boolean { diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css index 1139fe6c..4aa471c3 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.css @@ -81,6 +81,16 @@ white-space: nowrap; } +.agent-save-status.warn { + color: oklch(0.72 0.16 35); +} + +.field-hint.error { + display: block; + margin-top: 6px; + color: oklch(0.72 0.16 35); +} + .agent-actions-menu-trigger { position: relative; } diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx index 405ae2fe..4f5c7a60 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx @@ -1,16 +1,16 @@ import { Check, MoreHorizontal } from "lucide-react"; +import { errorMessage } from "@/api/client"; import { PROVIDERS, REASONING_EFFORTS, SHOW_AGENT_LIFECYCLE_ACTIONS } from "@/shared/constants/agents"; import { APIKeyField, CLIProxyAuthControl, EnvKeyValueEditor, - isBlank, NotifierControls, - profileBaseURLMissing, requiredFieldLabel, } from "@/components/business/ProfileControls"; import { WorkspaceFilePreview, WorkspaceFileTree } from "@/components/business/WorkspaceFileTree"; import { + agentProfilePageSaveDisabled, agentStatusLabel, agentModelID, agentToDraft, @@ -23,7 +23,6 @@ import { isNotifierRuntimeDraftOnAgentPage, normalizeAuthProviderName, normalizeRuntimeKind, - notifierFormIsComplete, } from "@/models/agents"; import type { AgentDraft, AgentLike } from "@/models/agents"; import type { IMConversation, TranslateFn } from "@/models/conversations"; @@ -54,12 +53,12 @@ export type AgentDetailPaneProps = { hasUnsavedChanges?: boolean; item: AgentLike; modelBusy?: boolean; + modelError?: unknown; models?: string[]; notice?: string; notifierWebhookPublicOrigin?: string; onDelete: AgentActionHandler; onDraftChange?: (draft: AgentDraft) => void; - onAvatarSave?: (avatar: string) => VoidOrPromise; onInvite: AgentActionHandler; onOpenDM: AgentActionHandler; onProviderLogin?: (provider: string) => VoidOrPromise; @@ -97,6 +96,7 @@ export function AgentDetailPane({ models = [], notice = "", modelBusy = false, + modelError = null, saving = false, publishBusy = false, saveError = "", @@ -113,7 +113,6 @@ export function AgentDetailPane({ workspaceFileError = "", onSelectWorkspaceFile = () => {}, onDraftChange, - onAvatarSave, onSave, onPublish, onProviderLogin, @@ -137,12 +136,7 @@ export function AgentDetailPane({ const canPublish = runtimeKind === "picoclaw_sandbox" || runtimeKind === "openclaw_sandbox"; const hasUnsavedChanges = hasUnsavedChangesProp ?? Boolean(draft && savedDraft && JSON.stringify(draft) !== JSON.stringify(savedDraft)); - const saveDisabled = - saving || - isBlank(draft?.name) || - (isNotifierRuntimeDraftOnAgentPage(draft, item) - ? !notifierFormIsComplete(draft, item) - : !draft?.model_id || profileBaseURLMissing(draft)); + const saveDisabled = agentProfilePageSaveDisabled(draft, item, { saving, savedDraft }); const updateDraft = (patch: Partial) => onDraftChange?.({ ...(draft || agentToDraft(item)), ...patch }); return (
@@ -153,13 +147,7 @@ export function AgentDetailPane({ value={draft.avatar || item.avatar} t={t} mode="edit" - onChange={(avatar) => { - if (onAvatarSave) { - void onAvatarSave(avatar); - return; - } - updateDraft({ avatar }); - }} + onChange={(avatar) => updateDraft({ avatar })} /> ) : ( @@ -234,6 +222,10 @@ export function AgentDetailPane({ > {t("agentSaveChanges")} + ) : draft && incomplete ? ( + + {t("agentProfileSetupRequired")} + ) : draft ? (