Files
NetMesh/components/AIChatSidePanelHelpers.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

298 lines
9.9 KiB
TypeScript

import {
resolveAgentModelSelection,
type AgentModelPreset,
type ExternalAgentConfig,
} from '../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../infrastructure/ai/managedAgents';
import { canonicalizeEffortEncodedModelId } from '../infrastructure/ai/composerPicker';
export { canonicalizeEffortEncodedModelId };
export type SdkRuntimeModelCatalog = {
currentModelId: string | null;
models: AgentModelPreset[];
};
export type SdkRuntimeModelCacheEntry = SdkRuntimeModelCatalog & {
updatedAt: number;
};
type SdkRuntimeModelCacheOptions = {
ttlMs?: number;
maxEntries?: number;
now?: () => number;
};
type SdkRuntimeModelRefreshOptions = {
force?: boolean;
};
const SDK_RUNTIME_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
const SDK_RUNTIME_MODEL_CACHE_MAX_ENTRIES = 64;
// Keep in sync with main-process SDK_MODEL_CACHE_ENV_KEYS: profile-affecting
// env must bust the renderer cache so we re-query after OpenCode config switches.
const MODEL_CACHE_ENV_HINTS = [
'HOME',
'USERPROFILE',
'XDG_CONFIG_HOME',
'OPENCODE_BIN',
'OPENCODE_CONFIG',
'OPENCODE_CONFIG_DIR',
'OPENCODE_CONFIG_CONTENT',
'CLAUDE_CODE_EXECUTABLE',
'CODEBUDDY_CODE_PATH',
'CURSOR_API_KEY',
'NETCATTY_CURSOR_AUTH_MODE',
'NETCATTY_CURSOR_CLI_BIN',
] as const;
function cloneCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
return {
currentModelId: catalog.currentModelId ?? null,
models: [...catalog.models],
};
}
function normalizeSdkRuntimeModelCatalog(catalog: SdkRuntimeModelCatalog): SdkRuntimeModelCatalog {
return {
currentModelId: catalog.currentModelId ?? null,
models: Array.isArray(catalog.models)
? catalog.models.filter((model): model is AgentModelPreset => Boolean(model?.id))
: [],
};
}
/**
* Inject Cursor auth-mode env for list-models IPC.
* Mirrors run-turn `buildAgentEnvWithStoredApiKey` (without decrypting API keys):
* persisted `agent.env` strips NETCATTY_CURSOR_* via sanitization, so list-models
* must re-inject from `cursorAuthMode` / `command` or main defaults to api-key.
*/
export function buildCursorListModelsAgentEnv(agent: {
env?: Record<string, string>;
cursorAuthMode?: 'cli-login' | 'api-key';
command?: string;
}): Record<string, string> | undefined {
const env = { ...(agent.env ?? {}) };
const authMode = agent.cursorAuthMode === 'cli-login' ? 'cli-login' : 'api-key';
env.NETCATTY_CURSOR_AUTH_MODE = authMode;
const cliBin = String(agent.command || '').trim();
if (authMode === 'cli-login' && cliBin && cliBin !== 'cursor') {
env.NETCATTY_CURSOR_CLI_BIN = cliBin;
}
return Object.keys(env).length > 0 ? env : undefined;
}
export function buildSdkRuntimeModelCacheKey(agent: {
id: string;
command?: string;
sdkBackend?: string;
acpCommand?: string;
env?: Record<string, string>;
codexRuntime?: 'sdk' | 'app-server';
grokRuntime?: 'acp' | 'streaming-json';
cursorAuthMode?: 'cli-login' | 'api-key';
}): string {
const sdkBackend = agent.sdkBackend || agent.acpCommand || '';
const envHints = MODEL_CACHE_ENV_HINTS.map((key) => `${key}=${agent.env?.[key] ?? ''}`);
// cursorAuthMode is the source of truth when NETCATTY_CURSOR_AUTH_MODE was
// stripped from persisted env; include it so toggling auth mode busts cache.
const cursorAuth = sdkBackend === 'cursor'
? (agent.cursorAuthMode === 'cli-login' ? 'cli-login' : 'api-key')
: '';
const grokRuntime = sdkBackend === 'grok'
? (agent.grokRuntime === 'streaming-json' ? 'streaming-json' : 'acp')
: '';
return [agent.id, sdkBackend, agent.command ?? '', agent.codexRuntime ?? 'sdk', grokRuntime, cursorAuth, ...envHints].join('\u0000');
}
export function createSdkRuntimeModelCache(options: SdkRuntimeModelCacheOptions = {}) {
const ttlMs = options.ttlMs ?? SDK_RUNTIME_MODEL_CACHE_TTL_MS;
const maxEntries = Math.max(1, options.maxEntries ?? SDK_RUNTIME_MODEL_CACHE_MAX_ENTRIES);
const now = options.now ?? (() => Date.now());
const entries = new Map<string, SdkRuntimeModelCacheEntry>();
const inFlight = new Map<string, Promise<SdkRuntimeModelCatalog>>();
const pruneEntries = () => {
const currentTime = now();
for (const [key, entry] of entries) {
if (currentTime - entry.updatedAt >= ttlMs) {
entries.delete(key);
}
}
while (entries.size > maxEntries) {
const oldestKey = entries.keys().next().value;
if (oldestKey === undefined) break;
entries.delete(oldestKey);
}
};
const touchEntry = (key: string, entry: SdkRuntimeModelCacheEntry) => {
entries.delete(key);
entries.set(key, entry);
};
return {
read(key: string): SdkRuntimeModelCacheEntry | null {
pruneEntries();
const entry = entries.get(key);
if (entry) touchEntry(key, entry);
return entry ? { ...cloneCatalog(entry), updatedAt: entry.updatedAt } : null;
},
size(): number {
pruneEntries();
return entries.size;
},
refresh(
key: string,
load: () => Promise<SdkRuntimeModelCatalog>,
refreshOptions: SdkRuntimeModelRefreshOptions = {},
): Promise<SdkRuntimeModelCatalog> {
pruneEntries();
const cached = entries.get(key);
if (!refreshOptions.force && cached && now() - cached.updatedAt < ttlMs) {
touchEntry(key, cached);
return Promise.resolve(cloneCatalog(cached));
}
const existing = inFlight.get(key);
if (existing) return existing;
const promise = Promise.resolve(load())
.then((catalog) => {
const normalized = normalizeSdkRuntimeModelCatalog(catalog);
if (normalized.models.length === 0 && !normalized.currentModelId) {
return cached ? cloneCatalog(cached) : cloneCatalog(normalized);
}
touchEntry(key, { ...cloneCatalog(normalized), updatedAt: now() });
pruneEntries();
return cloneCatalog(normalized);
})
.finally(() => {
if (inFlight.get(key) === promise) {
inFlight.delete(key);
}
});
inFlight.set(key, promise);
return promise;
},
};
}
export const sdkRuntimeModelCache = createSdkRuntimeModelCache();
export function mergeFallbackThinkingLevels(
runtime: AgentModelPreset[],
fallbacks: AgentModelPreset[],
): AgentModelPreset[] {
if (runtime.length === 0 || fallbacks.length === 0) return runtime;
const byId = new Map(fallbacks.map((preset) => [preset.id, preset]));
let changed = false;
const next = runtime.map((preset) => {
if (preset.thinkingLevels?.length) return preset;
const fallback = byId.get(preset.id);
if (!fallback?.thinkingLevels?.length) return preset;
changed = true;
return {
...preset,
thinkingLevels: [...fallback.thinkingLevels],
...(fallback.defaultThinkingLevel
? { defaultThinkingLevel: fallback.defaultThinkingLevel }
: {}),
...(fallback.encodeDefaultThinking === false
? { encodeDefaultThinking: false }
: {}),
};
});
return changed ? next : runtime;
}
export function agentModelPresetsShallowEqual(
left: AgentModelPreset[] | undefined,
right: AgentModelPreset[] | undefined,
): boolean {
if (left === right) return true;
if (!left || !right || left.length !== right.length) return false;
return left.every((preset, index) => {
const other = right[index];
return (
preset.id === other.id
&& preset.name === other.name
&& preset.defaultThinkingLevel === other.defaultThinkingLevel
&& (preset.thinkingLevels ?? []).join('\0') === (other.thinkingLevels ?? []).join('\0')
);
});
}
export function modelPresetMatchesId(preset: AgentModelPreset, modelId: string): boolean {
const canonical = canonicalizeEffortEncodedModelId(modelId);
if (preset.thinkingLevels?.length) {
return preset.id === canonical
|| preset.thinkingLevels.some((level) => `${preset.id}/${level}` === canonical);
}
return preset.id === canonical;
}
export function modelPresetsContainId(presets: AgentModelPreset[], modelId: string): boolean {
return presets.some((preset) => modelPresetMatchesId(preset, modelId));
}
export function normalizeStoredAgentModelSelection(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
): string | undefined {
if (!storedModelId) return undefined;
const canonical = canonicalizeEffortEncodedModelId(storedModelId);
const preset = presets.find((candidate) => modelPresetMatchesId(candidate, canonical));
if (!preset) return undefined;
return canonical === preset.id
? resolveAgentModelSelection(preset)
: canonical;
}
export function shouldLoadSdkRuntimeModels(agent?: ExternalAgentConfig): boolean {
const sdkBackend = getExternalAgentSdkBackend(agent);
return (sdkBackend === 'codex' && agent?.codexRuntime === 'app-server')
|| sdkBackend === 'claude'
|| sdkBackend === 'copilot'
|| sdkBackend === 'cursor'
|| sdkBackend === 'codebuddy'
|| sdkBackend === 'opencode'
|| sdkBackend === 'grok';
}
export function shouldAdoptSdkCurrentModel(
currentModelId: string | null | undefined,
storedModelId: string | null | undefined,
runtimePresets: AgentModelPreset[],
): boolean {
if (!currentModelId) return false;
return !storedModelId
|| runtimePresets.length === 0
|| !modelPresetsContainId(runtimePresets, storedModelId);
}
export function normalizeSdkRuntimeModelPresets(
models: AgentModelPreset[],
currentModelId: string | null | undefined,
): AgentModelPreset[] {
if (models.length > 0) return models;
if (!currentModelId) return [];
return [{ id: currentModelId, name: currentModelId }];
}
export function shouldUseStoredAgentModel(
storedModelId: string | null | undefined,
presets: AgentModelPreset[],
agent?: ExternalAgentConfig,
): boolean {
if (!storedModelId) return false;
return modelPresetsContainId(presets, storedModelId)
|| (presets.length === 0 && shouldLoadSdkRuntimeModels(agent));
}
export function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}