Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 28 additions & 13 deletions web/app/src/hooks/workspace/useAgentController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ import {
applyTemplateToDraft,
advanceAgentProgress,
agentDraftWithRuntimeFieldsFromAgent,
agentPageLLMProfileChanged,
agentRuntimePollSettled,
agentToDraft,
isAgentProfileDraftComplete,
isAgentProfileMarkedComplete,
availableManagerRebuildRuntimeOptions,
collectManagerTemplateVariants,
defaultManagerRebuildImageForRuntime,
Expand All @@ -50,6 +53,7 @@ import {
isNotificationBotDraftContext,
isNotifierRuntimeDraft,
isNotifierRuntimeDraftOnAgentPage,
notifierFormIsComplete,
mergeAgentIntoList,
normalizeAuthProviderName,
partitionWorkspaceAgentItems,
Expand Down Expand Up @@ -259,6 +263,7 @@ export function useAgentController({
const {
models: agentPageModels,
modelBusy: agentPageModelBusy,
modelError: agentPageModelError,
resetModels: resetAgentPageModels,
} = useProfileModelOptions({
draft: agentPageDraft,
Expand Down Expand Up @@ -830,8 +835,8 @@ export function useAgentController({
});
}

async function saveAgentPage(draftOverride?: AgentDraft): Promise<void> {
const draftToSave = draftOverride ?? agentPageDraft;
async function saveAgentPage(): Promise<void> {
const draftToSave = agentPageDraft;
if (!draftToSave || !selectedAgentForPage?.id) {
return;
}
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -910,22 +925,22 @@ 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 {
setAgentPageBusy(false);
}
}

async function saveAgentPageAvatar(avatar: string): Promise<void> {
if (!agentPageDraft) {
return;
}
const nextDraft = { ...agentPageDraft, avatar };
setAgentPageDraft(nextDraft);
await saveAgentPage(nextDraft);
}

async function publishAgentPage(): Promise<void> {
if (!selectedAgentForPage?.id || agentPagePublishBusy) {
return;
Expand Down Expand Up @@ -1298,6 +1313,7 @@ export function useAgentController({
hasUnsavedChanges: agentPageHasUnsavedChanges,
models: agentPageModels,
modelBusy: agentPageModelBusy,
modelError: agentPageModelError,
saving: agentPageBusy,
publishBusy: agentPagePublishBusy,
saveError: agentPageError,
Expand All @@ -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"),
Expand Down
74 changes: 68 additions & 6 deletions web/app/src/models/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1057,12 +1057,66 @@ export function isAgentProfileDraftComplete(draft: Partial<AgentDraft> | 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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -97,6 +96,7 @@ export function AgentDetailPane({
models = [],
notice = "",
modelBusy = false,
modelError = null,
saving = false,
publishBusy = false,
saveError = "",
Expand All @@ -113,7 +113,6 @@ export function AgentDetailPane({
workspaceFileError = "",
onSelectWorkspaceFile = () => {},
onDraftChange,
onAvatarSave,
onSave,
onPublish,
onProviderLogin,
Expand All @@ -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<AgentDraft>) => onDraftChange?.({ ...(draft || agentToDraft(item)), ...patch });
return (
<section className="entity-pane agent-detail-pane">
Expand All @@ -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 })}
/>
</div>
) : (
Expand Down Expand Up @@ -234,6 +222,10 @@ export function AgentDetailPane({
>
{t("agentSaveChanges")}
</Button>
) : draft && incomplete ? (
<span className="agent-save-status warn" role="status">
{t("agentProfileSetupRequired")}
</span>
) : draft ? (
<span className="agent-save-status" role="status">
<Check aria-hidden="true" size={16} strokeWidth={2.5} />
Expand Down Expand Up @@ -350,6 +342,9 @@ export function AgentDetailPane({
: []),
]}
/>
{modelError ? (
<span className="field-hint error">{errorMessage(modelError, t("modelLoadFailed"))}</span>
) : null}
</label>
<label className="field">
<span>{t("profileReasoning")}</span>
Expand Down
6 changes: 6 additions & 0 deletions web/app/src/shared/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,9 @@ export const messages = {
noChannels: "还没有房间。",
noDirectMessages: "还没有私信。",
modelLoadFailed: "模型加载失败",
agentProfileSetupRequired: "待完成配置",
profileSaveIncompleteError: "请先选择模型,并在 OpenAI API 模式下填写 Base URL 与 API Key。",
profileSetupIncompleteAfterSave: "保存未生效:请补全模型与连接信息后再保存。",
authConnected: "已连接",
authMissing: "需要登录",
authConnect: "连接",
Expand Down Expand Up @@ -1066,6 +1069,9 @@ export const messages = {
noChannels: "No rooms yet.",
noDirectMessages: "No direct messages yet.",
modelLoadFailed: "Failed to load models",
agentProfileSetupRequired: "Setup required",
profileSaveIncompleteError: "Select a model first. For OpenAI API, provide Base URL and API Key.",
profileSetupIncompleteAfterSave: "Save did not complete setup. Finish the model and connection details, then save again.",
authConnected: "Connected",
authMissing: "Login required",
authConnect: "Connect",
Expand Down
Loading
Loading