Files
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

800 lines
25 KiB
TypeScript

import type { AgentGUIComposerSettingsVM } from "./agentGuiNodeTypes";
import {
collapseModelOptionsToLatest,
groupModelOptionsByVendor
} from "./modelFamilies";
import { effectiveDefaultModelOption } from "./effectiveDefaultModel";
// Labels for the composer settings menus. Lives here (next to the pure menu
// model) so the model + the presentational component share one source; the
// component file re-exports it for existing importers.
export type AgentComposerSettingsMenuLabels = {
modelLabel: string;
modelSelectionLabel: string;
modelContextWindowSuffix: string;
modelTooltipVersionLabel: string;
defaultModel: string;
loadingOptions: string;
optionsLoadFailed?: string;
retry?: string;
retryTooltip?: string;
inheritedUnavailable: string;
reasoningLabel: string;
reasoningDegreeLabel: string;
reasoningOptionDefault: string;
reasoningOptionMinimal: string;
reasoningOptionLow: string;
reasoningOptionMedium: string;
reasoningOptionHigh: string;
reasoningOptionXHigh: string;
reasoningOptionMax: string;
reasoningOptionUltra: string;
speedLabel: string;
speedSelectionLabel: string;
speedOptionStandard: string;
speedOptionStandardDescription: string;
speedOptionFast: string;
speedOptionFastDescription: string;
permissionLabel: string;
planModeLabel: string;
permissionModeReadOnly?: string;
permissionModeAuto?: string;
permissionModeFullAccess?: string;
modelDescriptions: {
frontierComplexCoding: string;
everydayCoding: string;
smallFastCostEfficient: string;
codingOptimized: string;
ultraFastCoding: string;
professionalLongRunning: string;
};
};
export interface ComposerMenuOption {
value: string;
label: string;
description?: string;
summary?: string[];
tooltip?: ComposerModelOptionTooltip;
}
export interface ComposerModelOptionTooltip {
title: string;
description?: string;
consumptionMultiplier?: string;
contextWindow?: string;
version?: string;
}
export interface ComposerMenuSection {
/** Whether this dimension is configurable and has options to show. */
show: boolean;
/** The currently selected value ("" when none). */
selectedValue: string;
/** Display label for the current value (for the section/submenu trigger). */
selectedLabel: string;
/** Options with display labels already resolved. */
options: ComposerMenuOption[];
}
export interface ComposerMenuOptionGroup {
/** Vendor heading; null for the leading ungrouped entries (Auto etc.). */
label: string | null;
options: ComposerMenuOption[];
}
/** UI-local menu state fed into the pure model (search + history chrome). */
export interface ComposerModelMenuLocalState {
/** Raw filter text typed into the menu's search input. */
searchQuery?: string;
/** Favorite model ids for the composer target (localStorage chrome). */
favoriteModelIds?: readonly string[];
/** Most-recent-first model ids picked for the target (localStorage chrome). */
recentModelIds?: readonly string[];
}
/** Render the model filter input once the list exceeds this option count. */
export const COMPOSER_MODEL_SEARCH_THRESHOLD = 8;
export interface ComposerModelMenuModel {
/** The trigger should be disabled / the menu not openable. */
disabled: boolean;
trigger: {
isFast: boolean;
modelLabel: string;
reasoningLabel: string;
combinedLabel: string;
/** Render the single combined label vs. model + reasoning separately. */
showCombined: boolean;
};
model: ComposerMenuSection & {
/**
* Vendor-grouped view of `options` (Claude / GPT / ...); empty
* when the provider does not collapse its model list, in which case the
* menu renders the flat `options`.
*/
groups: ComposerMenuOptionGroup[];
/** Bound model access plan for the menu header badge; null when none. */
plan: { id: string; name: string } | null;
/**
* Plan-bound targets carry the source plan name in each option's
* `description`; render it as inline secondary text instead of the
* model-tooltip treatment.
*/
optionDescriptionInline: boolean;
/** The list is long enough to render the filter input. */
searchEnabled: boolean;
/** Normalized applied filter ("" when none). */
searchQuery: string;
/** Favorites group shown above recents; deduped out of `options`. */
favoriteOptions: ComposerMenuOption[];
/** Recently used group (most recent first); deduped out of `options`. */
recentOptions: ComposerMenuOption[];
/** Every favorited value present in the list, for star toggle states. */
favoriteValues: string[];
};
reasoning: ComposerMenuSection;
speed: ComposerMenuSection;
/**
* Active session with mid-session model switch support: the menu footer
* shows the "applies from the next request" hint.
*/
switchEffectHint: boolean;
}
/**
* Pure derivation of everything the model/reasoning/speed menu needs to render,
* from the composer view-model + labels. Keeping this free of React/radix makes
* the menu's behavior unit-testable and the presentational component thin, so a
* "nothing shows / nothing applies" bug is localizable to either this model or
* the small render that consumes it.
*/
export function buildComposerModelMenuModel(
composerSettings: AgentGUIComposerSettingsVM,
labels: AgentComposerSettingsMenuLabels,
localState: ComposerModelMenuLocalState = {}
): ComposerModelMenuModel {
const modelItems = modelOptionsWithSelectedValue(composerSettings);
const reasoningItems = reasoningOptionsWithSelectedValue(composerSettings);
const speedItems = speedOptionsWithSelectedValue(composerSettings);
const showModel =
composerSettings.supportsModel &&
modelItems.length > 0 &&
!composerSettings.modelUnavailable;
const showReasoning =
composerSettings.supportsReasoningEffort &&
reasoningItems.length > 0 &&
!composerSettings.reasoningUnavailable;
const showSpeed =
composerSettings.supportsSpeed &&
speedItems.length > 0 &&
!composerSettings.speedUnavailable;
const selectedModelValue = selectedComposerModelValue(composerSettings) ?? "";
const selectedReasoningValue =
selectedComposerReasoningValue(composerSettings) ?? "";
const selectedSpeedValue = selectedComposerSpeedValue(composerSettings) ?? "";
const effectiveDefaultModel = effectiveDefaultModelOption(
composerSettings,
modelItems
);
const modelLabel = resolveSelectedModelLabel(
composerSettings,
labels,
effectiveDefaultModel
);
// Only surface an effort label when the reasoning control is actually shown.
// Providers such as Cursor keep a stale/default draft effort ("high") even
// though reasoning is not configurable; showing it next to the model name
// reads like a real selection the user cannot change.
const reasoningLabel = showReasoning
? resolveSelectedReasoningLabel(composerSettings, labels)
: "";
const disabled =
composerSettings.isSettingsLoading ||
(!showModel && !showReasoning && !showSpeed);
return {
disabled,
trigger: {
isFast: selectedSpeedValue === "fast",
modelLabel,
reasoningLabel,
combinedLabel:
modelLabel === reasoningLabel
? modelLabel
: `${modelLabel} ${reasoningLabel}`.trim(),
showCombined: modelLabel === reasoningLabel || reasoningLabel.length === 0
},
model: (() => {
const allOptions = modelItems.map((option) =>
modelMenuOptionFromSettingOption(option, labels, effectiveDefaultModel)
);
const searchEnabled = allOptions.length > COMPOSER_MODEL_SEARCH_THRESHOLD;
const searchQuery = searchEnabled
? (localState.searchQuery ?? "").trim()
: "";
const visibleOptions = searchQuery
? filterComposerModelMenuOptions(allOptions, searchQuery)
: allOptions;
const favoriteValueSet = new Set(
(localState.favoriteModelIds ?? [])
.map((value) => value.trim())
.filter(Boolean)
);
const favoriteValues = allOptions
.map((option) => option.value)
.filter((value) => favoriteValueSet.has(value));
const favoriteOptions = visibleOptions.filter((option) =>
favoriteValueSet.has(option.value)
);
const visibleOptionsByValue = new Map(
visibleOptions.map((option) => [option.value, option])
);
const recentOptions = (localState.recentModelIds ?? [])
.map((value) => visibleOptionsByValue.get(value.trim()))
.filter(
(option): option is ComposerMenuOption =>
option !== undefined && !favoriteValueSet.has(option.value)
);
const pinnedValues = new Set([
...favoriteOptions.map((option) => option.value),
...recentOptions.map((option) => option.value)
]);
const options = visibleOptions.filter(
(option) => !pinnedValues.has(option.value)
);
return {
show: showModel,
selectedValue: selectedModelValue,
selectedLabel: modelLabel,
options,
groups:
showModel && composerSettings.collapseModelOptionsToLatest
? groupModelOptionsByVendor(options).filter(
(group) => group.options.length > 0
)
: [],
plan: composerSettings.modelPlan
? {
id: composerSettings.modelPlan.id,
name: composerSettings.modelPlan.name
}
: null,
optionDescriptionInline: Boolean(composerSettings.modelPlan),
searchEnabled,
searchQuery,
favoriteOptions,
recentOptions,
favoriteValues
};
})(),
reasoning: {
show: showReasoning,
selectedValue: selectedReasoningValue,
selectedLabel: reasoningLabel,
options: reasoningItems.map((option) => ({
value: option.value,
label: resolveReasoningOptionLabel(option.value, labels, option.label)
}))
},
speed: {
show: showSpeed,
selectedValue: selectedSpeedValue,
selectedLabel: resolveSpeedOptionLabel(selectedSpeedValue, labels),
options: speedItems.map((option) => ({
value: option.value,
label: resolveSpeedOptionLabel(option.value, labels),
description:
resolveSpeedOptionDescription(option.value, labels) ??
option.description
}))
},
switchEffectHint:
showModel && composerSettings.modelSwitchTakesEffectNextTurn === true
};
}
/** Case-insensitive filter over model label and id (value). */
export function filterComposerModelMenuOptions(
options: readonly ComposerMenuOption[],
searchQuery: string
): ComposerMenuOption[] {
const query = searchQuery.trim().toLowerCase();
if (!query) {
return [...options];
}
return options.filter(
(option) =>
option.label.toLowerCase().includes(query) ||
option.value.toLowerCase().includes(query)
);
}
function modelMenuOptionFromSettingOption(
option: AgentGUIComposerSettingsVM["availableModels"][number],
labels: AgentComposerSettingsMenuLabels,
effectiveDefaultModel:
| AgentGUIComposerSettingsVM["availableModels"][number]
| null
): ComposerMenuOption {
const resolvedOption =
option.value === "default" ? effectiveDefaultModel : null;
const defaultLabel = shortModelDisplayLabel(labels.defaultModel);
const resolvedLabel = resolvedOption
? shortModelDisplayLabel(resolvedOption.label)
: "";
const displayLabel =
option.value === "default"
? resolvedLabel
? `${defaultLabel} (${resolvedLabel})`
: defaultLabel
: formatModelDisplayLabel(option.label);
const description = resolveModelDescription(
option.value === "default"
? resolvedOption?.description
: option.description,
labels
);
const consumptionMultiplier =
option.value === "default"
? resolvedOption?.consumptionMultiplier
: option.consumptionMultiplier;
const presentation = modelOptionPresentation({
consumptionMultiplier,
description,
label: displayLabel,
labels
});
return {
value: option.value,
label: option.value === "default" ? displayLabel : presentation.label,
...(description ? { description } : {}),
...(presentation.summary.length > 0
? { summary: presentation.summary }
: {}),
...(presentation.tooltip ? { tooltip: presentation.tooltip } : {})
};
}
function modelOptionPresentation(input: {
consumptionMultiplier: string | undefined;
description: string | undefined;
label: string;
labels: Pick<
AgentComposerSettingsMenuLabels,
| "modelContextWindowSuffix"
| "modelTooltipVersionLabel"
| "reasoningOptionDefault"
| "reasoningOptionMinimal"
| "reasoningOptionLow"
| "reasoningOptionMedium"
| "reasoningOptionHigh"
| "reasoningOptionXHigh"
| "reasoningOptionMax"
| "reasoningOptionUltra"
| "speedOptionFast"
>;
}): {
label: string;
summary: string[];
tooltip?: ComposerModelOptionTooltip;
} {
const description = input.description?.trim() || "";
const parsed = parseModelDescription(description);
const consumptionMultiplier = input.consumptionMultiplier?.trim() || "";
const label = shortModelDisplayLabel(input.label);
const summary = uniqueNonEmpty([
parsed.contextWindow?.summary,
parsed.effort
? reasoningSummaryLabel(parsed.effort.summaryValue, input.labels)
: null,
parsed.speed === "fast" ? input.labels.speedOptionFast : null
]);
const tooltipDescription =
parsed.body || (description && !parsed.title ? description : "");
const tooltip =
description || summary.length > 0 || consumptionMultiplier
? {
title: parsed.title ?? label,
...(tooltipDescription ? { description: tooltipDescription } : {}),
...(consumptionMultiplier
? { consumptionMultiplier: `${consumptionMultiplier}x` }
: {}),
...(parsed.contextWindow
? {
contextWindow: `${parsed.contextWindow.summary} ${input.labels.modelContextWindowSuffix}`
}
: {}),
...(parsed.effort
? {
version: `${input.labels.modelTooltipVersionLabel}: ${parsed.effort.version}`
}
: {})
}
: undefined;
return { label, summary, ...(tooltip ? { tooltip } : {}) };
}
function uniqueNonEmpty(values: Array<string | null | undefined>): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value?.trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result;
}
function reasoningSummaryLabel(
effort: string,
labels: Pick<
AgentComposerSettingsMenuLabels,
| "reasoningOptionDefault"
| "reasoningOptionMinimal"
| "reasoningOptionLow"
| "reasoningOptionMedium"
| "reasoningOptionHigh"
| "reasoningOptionXHigh"
| "reasoningOptionMax"
| "reasoningOptionUltra"
>
): string {
return resolveReasoningOptionLabel(effort, labels);
}
function parseModelDescription(description: string): {
body: string;
contextWindow?: { summary: string };
effort?: { summaryValue: string; version: string };
speed?: "fast";
title?: string;
} {
const parts = description
.split(/[·•\n]/)
.map((part) => part.trim())
.filter(Boolean);
const firstPart = parts[0] ?? description.trim();
const contextWindow = contextWindowSummaryFromText(description);
const effort = effortFromText(description);
const speed = speedFromText(description);
const title = titleFromDescriptionPrefix(firstPart, contextWindow?.raw);
const bodyParts = (title ? parts.slice(1) : parts).filter(
(part) => !effortFromText(part)
);
return {
body: bodyParts.join(" · "),
...(contextWindow
? { contextWindow: { summary: contextWindow.summary } }
: {}),
...(effort ? { effort } : {}),
...(speed ? { speed } : {}),
...(title ? { title } : {})
};
}
function contextWindowSummaryFromText(
text: string
): { raw: string; summary: string } | null {
const match = text.match(
/\b(\d+(?:\.\d+)?\s*[kKmM])\s+(?:token\s+)?context(?:\s+window)?\b/
);
if (!match?.[1]) {
return null;
}
return {
raw: match[0],
summary: match[1].replace(/\s+/g, "").toUpperCase()
};
}
function titleFromDescriptionPrefix(
firstPart: string,
contextText: string | undefined
): string | undefined {
if (contextText) {
const contextIndex = firstPart
.toLowerCase()
.indexOf(contextText.toLowerCase());
if (contextIndex > 0) {
const title = firstPart
.slice(0, contextIndex)
.replace(/\bwith\s*$/i, "")
.trim();
return title ? formatModelDisplayLabel(title) : undefined;
}
}
if (/^[A-Za-z][A-Za-z0-9 ._-]*\d(?:[A-Za-z0-9 ._-]*)?$/.test(firstPart)) {
return formatModelDisplayLabel(firstPart);
}
return undefined;
}
function effortFromText(
text: string
): { summaryValue: string; version: string } | undefined {
const effortMatch = text
.toLowerCase()
.match(/\b(minimal|low|medium|high|x[-\s]?high|max|ultra)\s+effort\b/);
if (!effortMatch?.[1]) {
return undefined;
}
return {
summaryValue: effortMatch[1].replace(/\s+/g, "").replace("-", ""),
version: effortMatch[0]
};
}
function speedFromText(text: string): "fast" | undefined {
return /\bfast\b/i.test(text) ? "fast" : undefined;
}
export function formatModelDisplayLabel(label: string): string {
const trimmed = label.trim();
if (!trimmed) {
return label;
}
const capitalized = trimmed.replace(
/(^|[^A-Za-z0-9])([A-Za-z])/g,
(_match, prefix: string, letter: string) =>
`${prefix}${letter.toUpperCase()}`
);
return capitalized.replace(/gpt/gi, "GPT");
}
function shortModelDisplayLabel(label: string): string {
const formatted = formatModelDisplayLabel(label.replace(/\([^)]*\)/g, ""));
const family = formatted.trim().split(/\s+/)[0];
switch (family?.toLowerCase()) {
case "default":
case "opus":
case "sonnet":
case "haiku":
return family;
default:
return formatted.trim() || label;
}
}
export function resolveModelDescription(
description: string | undefined,
labels: Pick<AgentComposerSettingsMenuLabels, "modelDescriptions">
): string | undefined {
switch (description) {
case "Frontier model for complex coding, research, and real-world work.":
return labels.modelDescriptions.frontierComplexCoding;
case "Strong model for everyday coding.":
return labels.modelDescriptions.everydayCoding;
case "Small, fast, and cost-efficient model for simpler coding tasks.":
return labels.modelDescriptions.smallFastCostEfficient;
case "Coding-optimized model.":
return labels.modelDescriptions.codingOptimized;
case "Ultra-fast coding model.":
return labels.modelDescriptions.ultraFastCoding;
case "Optimized for professional work and long-running agents.":
return labels.modelDescriptions.professionalLongRunning;
default:
return description;
}
}
function resolveSelectedModelLabel(
composerSettings: AgentGUIComposerSettingsVM,
labels: Pick<
AgentComposerSettingsMenuLabels,
"defaultModel" | "inheritedUnavailable" | "loadingOptions"
>,
effectiveDefaultModel:
| AgentGUIComposerSettingsVM["availableModels"][number]
| null
): string {
const selectedValue = selectedComposerModelValue(composerSettings);
const selected = modelOptionsWithSelectedValue(composerSettings).find(
(option) => option.value === selectedValue
);
if (selected) {
if (selected.value === "default") {
const defaultLabel = shortModelDisplayLabel(labels.defaultModel);
const effectiveLabel = effectiveDefaultModel
? shortModelDisplayLabel(effectiveDefaultModel.label)
: "";
return effectiveLabel
? `${defaultLabel} (${effectiveLabel})`
: defaultLabel;
}
return shortModelDisplayLabel(selected.label);
}
// While composer options load, show a clear loading placeholder rather than
// falling through to the "Default" label (which reads like a real choice).
if (
composerSettings.isSettingsLoading ||
composerSettings.isModelOptionsLoading === true
) {
return labels.loadingOptions;
}
if (composerSettings.modelUnavailable) {
return labels.inheritedUnavailable;
}
const firstAvailableModel = composerSettings.availableModels[0]?.label;
if (firstAvailableModel) {
return shortModelDisplayLabel(firstAvailableModel);
}
return labels.defaultModel;
}
function resolveSelectedReasoningLabel(
composerSettings: AgentGUIComposerSettingsVM,
labels: AgentComposerSettingsMenuLabels
): string {
const selectedValue = selectedComposerReasoningValue(composerSettings);
const selected = reasoningOptionsWithSelectedValue(composerSettings).find(
(option) => option.value === selectedValue
);
if (selected) {
return resolveReasoningOptionLabel(selected.value, labels, selected.label);
}
if (composerSettings.reasoningUnavailable) {
return labels.inheritedUnavailable;
}
if (composerSettings.isSettingsLoading) {
return "";
}
if (composerSettings.availableReasoningEfforts.length === 0) {
return "";
}
return labels.reasoningLabel;
}
export function resolveReasoningOptionLabel(
value: string,
labels: Pick<
AgentComposerSettingsMenuLabels,
| "reasoningOptionDefault"
| "reasoningOptionMinimal"
| "reasoningOptionLow"
| "reasoningOptionMedium"
| "reasoningOptionHigh"
| "reasoningOptionXHigh"
| "reasoningOptionMax"
| "reasoningOptionUltra"
>,
providerLabel?: string
): string {
switch (value) {
case "default":
return labels.reasoningOptionDefault;
case "minimal":
return labels.reasoningOptionMinimal;
case "low":
return labels.reasoningOptionLow;
case "medium":
return labels.reasoningOptionMedium;
case "high":
return labels.reasoningOptionHigh;
case "xhigh":
return labels.reasoningOptionXHigh;
case "max":
return labels.reasoningOptionMax;
case "ultra":
return labels.reasoningOptionUltra;
default:
return providerLabel?.trim() || value;
}
}
export function resolveSpeedOptionLabel(
value: string,
labels: Pick<
AgentComposerSettingsMenuLabels,
"speedOptionStandard" | "speedOptionFast"
>
): string {
switch (value) {
case "standard":
return labels.speedOptionStandard;
case "fast":
return labels.speedOptionFast;
default:
return value;
}
}
function resolveSpeedOptionDescription(
value: string,
labels: Pick<
AgentComposerSettingsMenuLabels,
"speedOptionStandardDescription" | "speedOptionFastDescription"
>
): string | undefined {
switch (value) {
case "standard":
return labels.speedOptionStandardDescription;
case "fast":
return labels.speedOptionFastDescription;
default:
return undefined;
}
}
function selectedComposerModelValue(
composerSettings: AgentGUIComposerSettingsVM
): string | null {
return (
composerSettings.selectedModelValue ??
composerSettings.draftSettings.model ??
null
);
}
function selectedComposerReasoningValue(
composerSettings: AgentGUIComposerSettingsVM
): string | null {
return (
composerSettings.selectedReasoningEffortValue ??
composerSettings.draftSettings.reasoningEffort ??
null
);
}
function selectedComposerSpeedValue(
composerSettings: AgentGUIComposerSettingsVM
): string | null {
return (
composerSettings.selectedSpeedValue ??
composerSettings.draftSettings.speed ??
null
);
}
function modelOptionsWithSelectedValue(
composerSettings: AgentGUIComposerSettingsVM
): AgentGUIComposerSettingsVM["availableModels"] {
// Collapse to the latest version per family first, then re-guarantee the
// selected value: a previously chosen older version stays visible (and
// selectable) even though its family collapsed to a newer release.
const models = composerSettings.collapseModelOptionsToLatest
? collapseModelOptionsToLatest(composerSettings.availableModels)
: composerSettings.availableModels;
return optionsWithSelectedValue(
models,
selectedComposerModelValue(composerSettings)
);
}
function reasoningOptionsWithSelectedValue(
composerSettings: AgentGUIComposerSettingsVM
): AgentGUIComposerSettingsVM["availableReasoningEfforts"] {
return optionsWithSelectedValue(
composerSettings.availableReasoningEfforts,
selectedComposerReasoningValue(composerSettings)
);
}
function speedOptionsWithSelectedValue(
composerSettings: AgentGUIComposerSettingsVM
): AgentGUIComposerSettingsVM["availableSpeeds"] {
return optionsWithSelectedValue(
composerSettings.availableSpeeds,
selectedComposerSpeedValue(composerSettings)
);
}
// Ensures the currently-selected value is always present as an option, even if
// the provider's advertised list does not include it (stale/custom values).
function optionsWithSelectedValue<T extends { value: string; label: string }>(
options: readonly T[],
selectedValue: string | null
): T[] {
if (!selectedValue || options.some((o) => o.value === selectedValue)) {
return [...options];
}
return [{ value: selectedValue, label: selectedValue } as T, ...options];
}