Files
tutti/packages/agent/gui/agent-gui/agentGuiNode/controller/useAgentGUIComposerSettingsActions.ts
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

617 lines
22 KiB
TypeScript

import {
selectEngineSession,
type AgentActivityComposerOptions,
type AgentActivityTurn,
type AgentSessionEngine
} from "@tutti-os/agent-activity-core";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { useCallback, useRef } from "react";
import type { AgentGUIRuntime } from "../../../agentActivityRuntime";
import { translate } from "../../../i18n/index";
import type {
AgentSessionComposerSettings,
AgentSessionReasoningEffort
} from "../../../shared/agentSessionTypes";
import type { AgentGUINodeData } from "../../../types";
import {
normalizePlanIssueBudgetPreset,
planIssueBudgetPresetsEqual,
type PlanIssueBudgetPreset
} from "../../../shared/agentConversation/planImplementationPresentation";
import { useStableControllerEventCallback } from "./agentGuiController.stableHelpers";
import {
cloneComposerSettings,
nodeDataFromComposerSettings,
nodeDefaultDraftKey,
normalizePermissionModeId,
readNodeDefaultDraftSettings,
resolveEffectiveComposerSettings,
sameComposerSettings
} from "./agentGuiController.composerHelpers";
import {
enforceComposerModelBindingForHomeDefaults,
effectiveComposerSettingsFromOptions,
nodeDataMatchesComposerTarget,
sanitizeComposerSettingsForTarget,
type AgentGUIComposerTargetData
} from "./agentGuiController.composerPresentation";
import {
acknowledgeAgentGUIComposerDefaultsMutation,
createAgentGUIComposerDefaultsLedger,
prepareAcknowledgedComposerDefaultsAuthorityRead,
preserveAcknowledgedComposerDefaultsForReconciliation,
registerAgentGUIComposerDefaultsMutation,
removeRetiredComposerDefaults,
retireAcknowledgedComposerDefaultsForRead,
type AgentGUIComposerDefaultsLedger,
type AgentGUIComposerDefaultsAuthorityReconciler,
type AgentGUIComposerDefaultsAuthorityReadReceipt,
type AgentGUIComposerDefaultsMutation,
type AgentGUIRetiredComposerDefault
} from "./agentGuiComposerDefaultsReconciliation";
import { normalizeOptionalText } from "./agentGuiController.promptHelpers";
import {
composerDefaultsPatchFromSettings,
composerOptionsForTarget,
rememberComposerDefaultsFields,
type AgentGUIRememberComposerDefaultsInput,
type AgentGUIRememberComposerDefaultsResult
} from "./agentGuiController.providerHelpers";
import type { useAgentGUIActivation } from "./useAgentGUIActivation";
interface UseAgentGUIComposerSettingsActionsInput {
activation: ReturnType<typeof useAgentGUIActivation>;
activeCanonicalComposerSettings: AgentSessionComposerSettings;
activeConversationIdRef: RefObject<string | null>;
activeEngineActiveTurn: AgentActivityTurn | null;
agentActivityRuntime: AgentGUIRuntime;
composerSupportPermissionModeChangeDeferred: boolean;
dataRef: RefObject<AgentGUINodeData>;
defaultReasoningEffort: AgentSessionReasoningEffort | null;
draftSettingsBySessionIdRef: RefObject<
Record<string, AgentSessionComposerSettings>
>;
isMountedRef: RefObject<boolean>;
loadDraftComposerOptions(options?: {
force?: boolean;
section?: "core" | "capabilities" | "connectors";
waitForFreshModelCatalog?: boolean;
}): void;
onDataChangeRef: RefObject<
(updater: (current: AgentGUINodeData) => AgentGUINodeData) => void
>;
onComposerDefaultsAuthorityReloadedRef: RefObject<AgentGUIComposerDefaultsAuthorityReconciler>;
onRememberComposerDefaultsRef: RefObject<
| ((
input: AgentGUIRememberComposerDefaultsInput
) => void | Promise<AgentGUIRememberComposerDefaultsResult>)
| undefined
>;
onShowMessageRef: RefObject<
((message: string, tone?: "info" | "warning" | "error") => void) | undefined
>;
reloadComposerOptionsForTarget(input: {
settings: AgentSessionComposerSettings;
target: AgentGUIComposerTargetData;
}): Promise<void>;
selectedComposerTargetDataRef: RefObject<AgentGUIComposerTargetData>;
sessionEngine: AgentSessionEngine;
setDraftSettingsBySessionId: Dispatch<
SetStateAction<Record<string, AgentSessionComposerSettings>>
>;
setDetailError?: Dispatch<SetStateAction<string | null>>;
updateComposerSettingsRef: RefObject<
(settings: Partial<AgentSessionComposerSettings>) => void
>;
workspaceId: string;
}
export function useAgentGUIComposerSettingsActions(
input: UseAgentGUIComposerSettingsActionsInput
) {
const {
activation,
activeCanonicalComposerSettings,
activeConversationIdRef,
activeEngineActiveTurn,
agentActivityRuntime,
dataRef,
defaultReasoningEffort,
draftSettingsBySessionIdRef,
isMountedRef,
loadDraftComposerOptions,
onComposerDefaultsAuthorityReloadedRef,
onDataChangeRef,
onRememberComposerDefaultsRef,
onShowMessageRef,
reloadComposerOptionsForTarget,
selectedComposerTargetDataRef,
sessionEngine,
setDraftSettingsBySessionId,
setDetailError,
updateComposerSettingsRef,
workspaceId
} = input;
const composerSupport = {
permissionModeChangeDeferred:
input.composerSupportPermissionModeChangeDeferred
};
const composerDefaultsLedgerRef = useRef(
createAgentGUIComposerDefaultsLedger()
);
const retireAcknowledgedDefaultsForRead = useCallback(
(
receipt: AgentGUIComposerDefaultsAuthorityReadReceipt | null,
options: AgentActivityComposerOptions
) => {
if (!isMountedRef.current || !receipt) return;
const currentDraft =
draftSettingsBySessionIdRef.current[receipt.draftKey];
if (!currentDraft) return;
const authoritativeSettings =
effectiveComposerSettingsFromOptions(options) ?? {};
const retired = retireAcknowledgedComposerDefaultsForRead(
composerDefaultsLedgerRef.current,
receipt,
currentDraft,
authoritativeSettings
);
if (retired.length === 0) return;
draftSettingsBySessionIdRef.current = reconcileRetiredDraftMap(
draftSettingsBySessionIdRef.current,
receipt.draftKey,
retired
);
setDraftSettingsBySessionId((current) =>
reconcileRetiredDraftMap(current, receipt.draftKey, retired)
);
},
[draftSettingsBySessionIdRef, isMountedRef, setDraftSettingsBySessionId]
);
const prepareComposerDefaultsAuthorityRead = useCallback(
(
target: AgentGUIComposerTargetData,
settings: AgentSessionComposerSettings
) =>
prepareAcknowledgedComposerDefaultsAuthorityRead(
composerDefaultsLedgerRef.current,
nodeDefaultDraftKey(target.provider, target.agentTargetId),
settings
),
[]
);
onComposerDefaultsAuthorityReloadedRef.current = {
prepareRead: prepareComposerDefaultsAuthorityRead,
reconcileHomeDefaults: (target, options) => {
if (
!isMountedRef.current ||
activeConversationIdRef.current !== null ||
!nodeDataMatchesComposerTarget(
selectedComposerTargetDataRef.current.data,
target
)
) {
return;
}
const draftKey = nodeDefaultDraftKey(
target.provider,
target.agentTargetId
);
const currentDraft = draftSettingsBySessionIdRef.current[draftKey];
if (!currentDraft) {
return;
}
const reconciledDraft =
preserveAcknowledgedComposerDefaultsForReconciliation(
composerDefaultsLedgerRef.current,
draftKey,
currentDraft,
enforceComposerModelBindingForHomeDefaults(
sanitizeComposerSettingsForTarget({
settings: currentDraft,
target,
options
}),
options
)
);
if (sameComposerSettings(currentDraft, reconciledDraft)) {
return;
}
draftSettingsBySessionIdRef.current = {
...draftSettingsBySessionIdRef.current,
[draftKey]: reconciledDraft
};
setDraftSettingsBySessionId((current) => ({
...current,
[draftKey]: reconciledDraft
}));
onDataChangeRef.current((current) =>
nodeDataFromComposerSettings(
{
...current,
provider: target.provider,
agentTargetId: target.agentTargetId
},
reconciledDraft
)
);
},
reloaded: retireAcknowledgedDefaultsForRead
};
const updateComposerSettings = useCallback(
(nextSettings: Partial<AgentSessionComposerSettings>) => {
// A model validation failure belongs to the model that was submitted.
// Once the user selects another model, retaining that failure makes the
// new valid selection look broken before it has even been submitted.
if (normalizeOptionalText(nextSettings.model) !== null) {
setDetailError?.(null);
}
// Values pass through unclamped: the toggle visibility is capability
// gated and the daemon clamps persisted settings per provider.
const supportedNextSettings: Partial<AgentSessionComposerSettings> = {
...nextSettings
};
// Persistent selections only originate from rendered menu values. A
// transient empty select value during options refresh is not a user
// intent and must not clear either the optimistic draft or defaults.
for (const field of rememberComposerDefaultsFields) {
if (field === "codexSaverMode" || field === "rtkSaverMode") {
continue;
}
if (
supportedNextSettings[field] !== undefined &&
normalizeOptionalText(supportedNextSettings[field]) === null
) {
delete supportedNextSettings[field];
}
}
const agentSessionId = activeConversationIdRef.current;
if (!agentSessionId) {
const targetData = selectedComposerTargetDataRef.current;
const defaultDraftKey = nodeDefaultDraftKey(
targetData.provider,
targetData.agentTargetId
);
const storedIntent = readNodeDefaultDraftSettings({
data: targetData.data,
defaultReasoningEffort,
drafts: draftSettingsBySessionIdRef.current
});
const previousSettings = resolveEffectiveComposerSettings({
settings: storedIntent
});
const mergedIntent: AgentSessionComposerSettings = {
...storedIntent,
...supportedNextSettings
};
for (const field of rememberComposerDefaultsFields) {
if (supportedNextSettings[field] !== undefined) {
Object.assign(mergedIntent, {
[field]:
field === "codexSaverMode" || field === "rtkSaverMode"
? supportedNextSettings[field] === true
: normalizeOptionalText(supportedNextSettings[field])
});
}
}
const snapshotComposerOptions = composerOptionsForTarget({
snapshot: agentActivityRuntime.getSnapshot(workspaceId),
target: targetData
});
draftSettingsBySessionIdRef.current = {
...draftSettingsBySessionIdRef.current,
[defaultDraftKey]: mergedIntent
};
setDraftSettingsBySessionId((current) => ({
...current,
[defaultDraftKey]: mergedIntent
}));
const rememberedDefaultsPatch = composerDefaultsPatchFromSettings(
supportedNextSettings,
mergedIntent
);
if (rememberedDefaultsPatch) {
const mutation = registerAgentGUIComposerDefaultsMutation(
composerDefaultsLedgerRef.current,
defaultDraftKey,
rememberedDefaultsPatch
);
const acknowledgement = invokeRememberComposerDefaults(
onRememberComposerDefaultsRef.current,
{
agentTargetId: targetData.agentTargetId,
provider: targetData.provider,
defaults: rememberedDefaultsPatch
}
);
if (targetData.agentTargetId && acknowledgement) {
void reconcileAcknowledgedHomeDefaults({
acknowledgement,
draftKey: defaultDraftKey,
draftSettingsBySessionIdRef,
isMountedRef,
ledger: composerDefaultsLedgerRef.current,
mutation,
reloadComposerOptionsForTarget,
target: targetData
}).catch(() => undefined);
}
}
void agentActivityRuntime.trackDraftComposerSettingsChange?.({
workspaceId,
provider: targetData.provider,
previousSettings,
nextSettings: resolveEffectiveComposerSettings({
settings: mergedIntent
})
});
loadDraftComposerOptions(
snapshotComposerOptions?.behavior
?.refreshModelOptionsAfterSettings === true
? { force: true }
: undefined
);
return;
}
const canonicalSession = selectEngineSession(
sessionEngine.getSnapshot(),
agentSessionId
);
// The optimistic pre-activation window (see startConversation): the id
// is already the active conversation but the backend session has not
// attached yet, so there is no control state to read settings from or
// send an update RPC against. Composer changes here are still applied
// to the local view (so the control reflects the click immediately)
// and queued for the flush once activation resolves.
const isPreActivationSession =
canonicalSession === null &&
activation.stateFor(agentSessionId) === "activating";
const sessionSettings = cloneComposerSettings(
canonicalSession ? activeCanonicalComposerSettings : null
);
const nextPermission =
supportedNextSettings.permissionModeId !== undefined
? normalizeOptionalText(supportedNextSettings.permissionModeId)
: undefined;
const currentPermission = normalizeOptionalText(
sessionSettings?.permissionModeId
);
const nextModel =
supportedNextSettings.model !== undefined
? normalizeOptionalText(supportedNextSettings.model)
: undefined;
const currentModel = normalizeOptionalText(sessionSettings?.model);
const nextReasoningEffort =
supportedNextSettings.reasoningEffort !== undefined
? (supportedNextSettings.reasoningEffort ?? null)
: undefined;
const currentReasoningEffort = sessionSettings?.reasoningEffort ?? null;
const nextSpeed =
supportedNextSettings.speed !== undefined
? (supportedNextSettings.speed ?? null)
: undefined;
const currentSpeed = sessionSettings?.speed ?? null;
const nextPlanMode = supportedNextSettings.planMode;
const currentPlanMode = sessionSettings?.planMode ?? false;
const nextBrowserUse = supportedNextSettings.browserUse;
const currentBrowserUse = sessionSettings?.browserUse ?? true;
const nextComputerUse = supportedNextSettings.computerUse;
const currentComputerUse = sessionSettings?.computerUse ?? true;
const sessionSettingsPatch: AgentSessionComposerSettings = {};
const rememberedDefaultsPatch = composerDefaultsPatchFromSettings(
supportedNextSettings,
supportedNextSettings as AgentSessionComposerSettings
);
if (rememberedDefaultsPatch) {
const defaultAgentTargetId =
normalizeOptionalText(canonicalSession?.agentTargetId) ??
normalizeOptionalText(dataRef.current.agentTargetId);
const defaultProvider =
canonicalSession?.provider ?? dataRef.current.provider;
const saving = invokeRememberComposerDefaults(
onRememberComposerDefaultsRef.current,
{
agentTargetId: defaultAgentTargetId,
provider: defaultProvider,
defaults: rememberedDefaultsPatch
}
);
if (saving) {
// Defaults persistence is independent from the active-session
// command and must remain silent on both sync and async failures.
void saving.catch(() => undefined);
}
}
if (nextModel !== undefined && nextModel !== currentModel) {
sessionSettingsPatch.model = nextModel;
}
if (
nextReasoningEffort !== undefined &&
nextReasoningEffort !== currentReasoningEffort
) {
sessionSettingsPatch.reasoningEffort = nextReasoningEffort;
}
if (nextSpeed !== undefined && nextSpeed !== currentSpeed) {
sessionSettingsPatch.speed = nextSpeed;
}
if (nextPlanMode !== undefined && nextPlanMode !== currentPlanMode) {
sessionSettingsPatch.planMode = nextPlanMode;
}
if (
nextBrowserUse !== undefined &&
nextBrowserUse !== currentBrowserUse
) {
sessionSettingsPatch.browserUse = nextBrowserUse;
}
if (
nextComputerUse !== undefined &&
nextComputerUse !== currentComputerUse
) {
sessionSettingsPatch.computerUse = nextComputerUse;
}
if (
nextPermission !== undefined &&
nextPermission &&
nextPermission !== currentPermission &&
(canonicalSession !== null || isPreActivationSession)
) {
sessionSettingsPatch.permissionModeId =
normalizePermissionModeId(nextPermission);
// Descriptor capability data decides whether an in-flight change is
// deferred until the next turn. Pre-activation has no turn to defer.
const turnPhase = activeEngineActiveTurn?.phase;
const isTurnInFlight =
turnPhase === "running" || turnPhase === "submitted";
if (composerSupport.permissionModeChangeDeferred && isTurnInFlight) {
onShowMessageRef.current?.(
translate("messages.agentPermissionModeAppliesNextTurn"),
"info"
);
}
}
if (
Object.keys(sessionSettingsPatch).length > 0 &&
(canonicalSession !== null || isPreActivationSession)
) {
if (isPreActivationSession) {
sessionEngine.dispatch({
type: "activation/settingsPatched",
agentSessionId,
settings: { ...sessionSettingsPatch }
});
} else {
sessionEngine.updateSessionSettings({
agentSessionId,
settings: { ...sessionSettingsPatch }
});
}
return;
}
},
[
activation,
activeCanonicalComposerSettings,
defaultReasoningEffort,
composerSupport.permissionModeChangeDeferred,
loadDraftComposerOptions,
reloadComposerOptionsForTarget,
sessionEngine,
workspaceId,
setDetailError
]
);
updateComposerSettingsRef.current = updateComposerSettings;
const updatePlanIssueBudgetPreset = useStableControllerEventCallback(
(preset: PlanIssueBudgetPreset) => {
const normalized = normalizePlanIssueBudgetPreset(preset);
if (!normalized) return;
onDataChangeRef.current((current) =>
planIssueBudgetPresetsEqual(current.planIssueBudgetPreset, normalized)
? current
: { ...current, planIssueBudgetPreset: normalized }
);
}
);
// Recovery entry for the composer-options terminal error state. Leave the
// request non-forced so Activity Core joins an already-running load when a
// user double-clicks the retry control instead of superseding it.
const retryComposerOptions = useStableControllerEventCallback(
(options?: {
force?: boolean;
section?: "core" | "capabilities" | "connectors";
waitForFreshModelCatalog?: boolean;
}) => {
if (options === undefined) {
loadDraftComposerOptions();
return;
}
loadDraftComposerOptions(options);
}
);
return {
retryComposerOptions,
updateComposerSettings,
updatePlanIssueBudgetPreset
};
}
function invokeRememberComposerDefaults(
callback:
| ((
input: AgentGUIRememberComposerDefaultsInput
) => void | Promise<AgentGUIRememberComposerDefaultsResult>)
| undefined,
input: AgentGUIRememberComposerDefaultsInput
): Promise<AgentGUIRememberComposerDefaultsResult> | undefined {
if (!callback) return undefined;
try {
const result = callback(input);
return result === undefined ? undefined : Promise.resolve(result);
} catch (error) {
return Promise.reject(error);
}
}
async function reconcileAcknowledgedHomeDefaults(input: {
acknowledgement: Promise<AgentGUIRememberComposerDefaultsResult>;
draftKey: string;
draftSettingsBySessionIdRef: RefObject<
Record<string, AgentSessionComposerSettings>
>;
isMountedRef: RefObject<boolean>;
ledger: AgentGUIComposerDefaultsLedger;
mutation: AgentGUIComposerDefaultsMutation;
reloadComposerOptionsForTarget(input: {
settings: AgentSessionComposerSettings;
target: AgentGUIComposerTargetData;
}): Promise<void>;
target: AgentGUIComposerTargetData;
}): Promise<void> {
const result = await input.acknowledgement;
if (!input.isMountedRef.current) return;
const currentDraft =
input.draftSettingsBySessionIdRef.current[input.draftKey];
if (!currentDraft) {
return;
}
if (
!acknowledgeAgentGUIComposerDefaultsMutation(
input.ledger,
input.mutation,
result
)
) {
return;
}
await input.reloadComposerOptionsForTarget({
settings: currentDraft,
target: input.target
});
}
function reconcileRetiredDraftMap(
current: Record<string, AgentSessionComposerSettings>,
draftKey: string,
retired: readonly AgentGUIRetiredComposerDefault[]
): Record<string, AgentSessionComposerSettings> {
const draft = current[draftKey];
if (!draft) return current;
const nextDraft = removeRetiredComposerDefaults(draft, retired);
if (Object.keys(draft).length === Object.keys(nextDraft).length)
return current;
const next = { ...current };
if (Object.keys(nextDraft).length === 0) {
delete next[draftKey];
} else {
next[draftKey] = nextDraft;
}
return next;
}