[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
265
infrastructure/ai/composerPicker.ts
Normal file
265
infrastructure/ai/composerPicker.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { AgentModelPreset, ProviderConfig } from './types';
|
||||
import { formatThinkingLabel, PROVIDER_PRESETS, resolveAgentModelSelection } from './types';
|
||||
|
||||
export const CATTY_REASONING_LEVELS = ['off', 'low', 'medium', 'high'] as const;
|
||||
export type CattyReasoningLevel = (typeof CATTY_REASONING_LEVELS)[number];
|
||||
|
||||
export const CLAUDE_REASONING_LEVELS = ['low', 'medium', 'high', 'max'] as const;
|
||||
|
||||
export const COMPOSER_RECENT_MODEL_LIMIT = 6;
|
||||
export const COMPOSER_PINNED_MODEL_LIMIT = 8;
|
||||
|
||||
export interface ComposerPickerModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
export interface ComposerModelPrefEntry {
|
||||
providerId?: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export interface ComposerModelPrefs {
|
||||
recent: ComposerModelPrefEntry[];
|
||||
pinned: ComposerModelPrefEntry[];
|
||||
}
|
||||
|
||||
export function isCattyReasoningLevel(value: string | null | undefined): value is CattyReasoningLevel {
|
||||
return CATTY_REASONING_LEVELS.some((level) => level === value);
|
||||
}
|
||||
|
||||
export function normalizeCattyReasoningLevel(
|
||||
value: string | null | undefined,
|
||||
): CattyReasoningLevel {
|
||||
return isCattyReasoningLevel(value) ? value : 'off';
|
||||
}
|
||||
|
||||
export function composerModelPrefKey(entry: ComposerModelPrefEntry): string {
|
||||
return entry.providerId ? `${entry.providerId}::${entry.modelId}` : entry.modelId;
|
||||
}
|
||||
|
||||
export function sameComposerModelPref(
|
||||
left: ComposerModelPrefEntry,
|
||||
right: ComposerModelPrefEntry,
|
||||
): boolean {
|
||||
return left.modelId === right.modelId && (left.providerId ?? '') === (right.providerId ?? '');
|
||||
}
|
||||
|
||||
export function upsertComposerPrefFront(
|
||||
entries: ComposerModelPrefEntry[],
|
||||
next: ComposerModelPrefEntry,
|
||||
limit: number,
|
||||
): ComposerModelPrefEntry[] {
|
||||
const filtered = entries.filter((entry) => !sameComposerModelPref(entry, next));
|
||||
return [next, ...filtered].slice(0, limit);
|
||||
}
|
||||
|
||||
export function toggleComposerPinnedPref(
|
||||
entries: ComposerModelPrefEntry[],
|
||||
target: ComposerModelPrefEntry,
|
||||
limit = COMPOSER_PINNED_MODEL_LIMIT,
|
||||
): ComposerModelPrefEntry[] {
|
||||
if (entries.some((entry) => sameComposerModelPref(entry, target))) {
|
||||
return entries.filter((entry) => !sameComposerModelPref(entry, target));
|
||||
}
|
||||
return upsertComposerPrefFront(entries, target, limit);
|
||||
}
|
||||
|
||||
export function parseComposerModelPrefs(value: unknown): ComposerModelPrefs {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return { recent: [], pinned: [] };
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return {
|
||||
recent: parsePrefEntries(record.recent).slice(0, COMPOSER_RECENT_MODEL_LIMIT),
|
||||
pinned: parsePrefEntries(record.pinned).slice(0, COMPOSER_PINNED_MODEL_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePrefEntries(value: unknown): ComposerModelPrefEntry[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const entries: ComposerModelPrefEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const modelId = typeof (item as { modelId?: unknown }).modelId === 'string'
|
||||
? (item as { modelId: string }).modelId.trim()
|
||||
: '';
|
||||
if (!modelId) continue;
|
||||
const providerId = typeof (item as { providerId?: unknown }).providerId === 'string'
|
||||
? (item as { providerId: string }).providerId.trim()
|
||||
: undefined;
|
||||
const entry: ComposerModelPrefEntry = providerId ? { providerId, modelId } : { modelId };
|
||||
const key = composerModelPrefKey(entry);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function resolveComposerEnterModelId(input: {
|
||||
query: string;
|
||||
models: ComposerPickerModel[];
|
||||
grouped: {
|
||||
pinned: ComposerPickerModel[];
|
||||
recent: ComposerPickerModel[];
|
||||
rest: ComposerPickerModel[];
|
||||
};
|
||||
filtered: ComposerPickerModel[];
|
||||
showCustom: boolean;
|
||||
}): string | undefined {
|
||||
const trimmed = input.query.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const exact = input.models.find((model) => model.id.toLowerCase() === trimmed.toLowerCase());
|
||||
if (exact) return exact.id;
|
||||
if (input.showCustom) return trimmed;
|
||||
return input.grouped.pinned[0]?.id
|
||||
?? input.grouped.recent[0]?.id
|
||||
?? input.grouped.rest[0]?.id
|
||||
?? input.filtered[0]?.id;
|
||||
}
|
||||
|
||||
export function filterComposerModels(
|
||||
models: ComposerPickerModel[],
|
||||
query: string,
|
||||
): ComposerPickerModel[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return models;
|
||||
return models.filter((model) => (
|
||||
model.id.toLowerCase().includes(q)
|
||||
|| model.name.toLowerCase().includes(q)
|
||||
|| (model.description ?? '').toLowerCase().includes(q)
|
||||
));
|
||||
}
|
||||
|
||||
export function buildProviderSeedModels(provider: ProviderConfig): ComposerPickerModel[] {
|
||||
const byId = new Map<string, ComposerPickerModel>();
|
||||
const defaultModel = provider.defaultModel?.trim();
|
||||
if (defaultModel) {
|
||||
byId.set(defaultModel, { id: defaultModel, name: defaultModel });
|
||||
}
|
||||
const presetModels = PROVIDER_PRESETS[provider.providerId]?.defaultModels ?? [];
|
||||
for (const modelId of presetModels) {
|
||||
const id = modelId.trim();
|
||||
if (!id || byId.has(id)) continue;
|
||||
byId.set(id, { id, name: id });
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
export function mergeComposerModels(
|
||||
...lists: Array<Iterable<ComposerPickerModel> | undefined>
|
||||
): ComposerPickerModel[] {
|
||||
const byId = new Map<string, ComposerPickerModel>();
|
||||
for (const list of lists) {
|
||||
if (!list) continue;
|
||||
for (const model of list) {
|
||||
const id = model.id.trim();
|
||||
if (!id) continue;
|
||||
const existing = byId.get(id);
|
||||
if (!existing) {
|
||||
byId.set(id, {
|
||||
id,
|
||||
name: model.name || id,
|
||||
...(model.description ? { description: model.description } : {}),
|
||||
...(model.contextWindow != null ? { contextWindow: model.contextWindow } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const contextWindow = existing.contextWindow ?? model.contextWindow;
|
||||
byId.set(id, {
|
||||
id,
|
||||
name: existing.name === existing.id && model.name ? model.name : existing.name,
|
||||
...(existing.description || model.description
|
||||
? { description: existing.description || model.description }
|
||||
: {}),
|
||||
...(contextWindow != null ? { contextWindow } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
export function canonicalizeEffortEncodedModelId(modelId: string): string {
|
||||
const queryIndex = modelId.indexOf('?');
|
||||
if (queryIndex < 0) return modelId;
|
||||
const id = modelId.slice(0, queryIndex);
|
||||
const params = new URLSearchParams(modelId.slice(queryIndex + 1));
|
||||
const keys = [...params.keys()];
|
||||
const effort = params.get('effort');
|
||||
if (keys.length === 1 && keys[0] === 'effort' && effort) {
|
||||
return `${id}/${effort}`;
|
||||
}
|
||||
return modelId;
|
||||
}
|
||||
|
||||
export function resolveThinkingSelection(
|
||||
selectedModelId: string | undefined,
|
||||
presets: AgentModelPreset[],
|
||||
): { preset?: AgentModelPreset; thinking?: string } {
|
||||
if (!selectedModelId) return {};
|
||||
const canonical = canonicalizeEffortEncodedModelId(selectedModelId);
|
||||
const direct = presets.find((preset) => preset.id === canonical);
|
||||
if (direct) return { preset: direct };
|
||||
const viaThinking = presets.find(
|
||||
(preset) => preset.thinkingLevels?.some((level) => `${preset.id}/${level}` === canonical),
|
||||
);
|
||||
if (!viaThinking) return {};
|
||||
return {
|
||||
preset: viaThinking,
|
||||
thinking: canonical.slice(viaThinking.id.length + 1),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveModelSelectionWithThinking(
|
||||
preset: AgentModelPreset,
|
||||
preferredThinking?: string,
|
||||
): string {
|
||||
const levels = preset.thinkingLevels;
|
||||
if (!levels?.length) return preset.id;
|
||||
if (preferredThinking && levels.includes(preferredThinking)) {
|
||||
return `${preset.id}/${preferredThinking}`;
|
||||
}
|
||||
return resolveAgentModelSelection(preset);
|
||||
}
|
||||
|
||||
export function formatComposerThinkingLabel(level: string): string {
|
||||
if (level === 'off') return 'Off';
|
||||
return formatThinkingLabel(level);
|
||||
}
|
||||
|
||||
export function resolvePinnedAndRecentModels(input: {
|
||||
models: ComposerPickerModel[];
|
||||
prefs: ComposerModelPrefs;
|
||||
providerId?: string;
|
||||
/** Catty custom IDs only. External catalogs must not resurrect stale prefs. */
|
||||
allowMissing?: boolean;
|
||||
}): {
|
||||
pinned: ComposerPickerModel[];
|
||||
recent: ComposerPickerModel[];
|
||||
rest: ComposerPickerModel[];
|
||||
} {
|
||||
const byId = new Map(input.models.map((model) => [model.id, model]));
|
||||
const matchesScope = (entry: ComposerModelPrefEntry) => (
|
||||
!input.providerId || !entry.providerId || entry.providerId === input.providerId
|
||||
);
|
||||
const allowMissing = input.allowMissing ?? false;
|
||||
const resolve = (entries: ComposerModelPrefEntry[]) => (
|
||||
entries
|
||||
.filter(matchesScope)
|
||||
.map((entry) => byId.get(entry.modelId) ?? (allowMissing ? { id: entry.modelId, name: entry.modelId } : undefined))
|
||||
.filter((model): model is ComposerPickerModel => model != null)
|
||||
);
|
||||
const pinned = resolve(input.prefs.pinned);
|
||||
const pinnedIds = new Set(pinned.map((model) => model.id));
|
||||
const recent = resolve(input.prefs.recent).filter((model) => !pinnedIds.has(model.id));
|
||||
const reservedIds = new Set([...pinnedIds, ...recent.map((model) => model.id)]);
|
||||
return {
|
||||
pinned,
|
||||
recent,
|
||||
rest: input.models.filter((model) => !reservedIds.has(model.id)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user