Files
dbx/apps/desktop/src/composables/useAiModelCatalog.ts
T

179 lines
6.3 KiB
TypeScript

import { reactive } from "vue";
import * as api from "@/lib/backend/api";
import type { AiModelInfo } from "@/lib/backend/tauri";
import type { AiConfigItem, AiEffortCapability } from "@/types/ai";
const CATALOG_TTL_MS = 5 * 60 * 1000;
export interface AiModelCatalogEntry {
status: "idle" | "loading" | "ready" | "error";
models: AiModelInfo[];
signature?: string;
error?: string;
loadedAt?: number;
}
interface AiEffortCatalogEntry {
status: "idle" | "loading" | "ready" | "error";
capability?: AiEffortCapability;
signature?: string;
error?: string;
loadedAt?: number;
}
const catalogs = reactive(new Map<string, AiModelCatalogEntry>());
const effortCatalogs = reactive(new Map<string, AiEffortCatalogEntry>());
const modelRequests = new Map<string, Promise<AiModelInfo[]>>();
const effortRequests = new Map<string, Promise<AiEffortCapability>>();
function effortKey(configId: string, modelId: string): string {
return JSON.stringify([configId, modelId]);
}
function sortedRecord(record: Record<string, string> | undefined): [string, string][] {
return Object.entries(record ?? {}).sort(([left], [right]) => left.localeCompare(right));
}
function fingerprint(value: string): string {
let first = 0xdeadbeef;
let second = 0x41c6ce57;
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
first = Math.imul(first ^ code, 2654435761);
second = Math.imul(second ^ code, 1597334677);
}
first = Math.imul(first ^ (first >>> 16), 2246822507) ^ Math.imul(second ^ (second >>> 13), 3266489909);
second = Math.imul(second ^ (second >>> 16), 2246822507) ^ Math.imul(first ^ (first >>> 13), 3266489909);
return `${(second >>> 0).toString(16).padStart(8, "0")}${(first >>> 0).toString(16).padStart(8, "0")}`;
}
function configSignature(config: AiConfigItem): string {
return JSON.stringify({
provider: config.provider,
authMethod: config.authMethod,
apiStyle: config.apiStyle,
proxyEnabled: config.proxyEnabled ?? false,
contextWindow: config.contextWindow ?? null,
codexCliPath: config.codexCliPath ?? null,
claudeCodeCliPath: config.claudeCodeCliPath ?? null,
piAgentCliPath: config.piAgentCliPath ?? null,
connectionFingerprint: fingerprint(
JSON.stringify({
apiKey: config.apiKey,
endpoint: config.endpoint,
proxyUrl: config.proxyUrl ?? "",
codexCliEnv: sortedRecord(config.codexCliEnv),
claudeCodeCliEnv: sortedRecord(config.claudeCodeCliEnv),
piAgentCliEnv: sortedRecord(config.piAgentCliEnv),
}),
),
});
}
function fresh(loadedAt: number | undefined): boolean {
return typeof loadedAt === "number" && Date.now() - loadedAt < CATALOG_TTL_MS;
}
function configPayload(config: AiConfigItem, modelId = config.model): AiConfigItem {
return { ...config, model: modelId, runtimeEffort: null };
}
async function loadModels(config: AiConfigItem, force = false): Promise<AiModelInfo[]> {
const signature = configSignature(config);
const current = catalogs.get(config.id);
if (!force && current?.status === "ready" && current.signature === signature && fresh(current.loadedAt)) {
return current.models;
}
const requestKey = JSON.stringify([config.id, signature]);
const pending = modelRequests.get(requestKey);
if (pending) return pending;
const previousModels = current?.signature === signature ? current.models : [];
catalogs.set(config.id, { status: "loading", models: previousModels, signature });
const request = api
.aiListModels(configPayload(config))
.then((models) => {
const seen = new Set<string>();
const uniqueModels = models.filter((model) => {
const id = model.id.trim();
if (!id || seen.has(id)) return false;
seen.add(id);
return true;
});
if (catalogs.get(config.id)?.signature !== signature) return uniqueModels;
catalogs.set(config.id, { status: "ready", models: uniqueModels, signature, loadedAt: Date.now() });
for (const model of uniqueModels) {
if (model.effortCapability) {
effortCatalogs.set(effortKey(config.id, model.id), {
status: "ready",
capability: model.effortCapability,
signature,
loadedAt: Date.now(),
});
}
}
return uniqueModels;
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (catalogs.get(config.id)?.signature === signature) {
catalogs.set(config.id, { status: "error", models: previousModels, signature, error: message });
}
throw error;
})
.finally(() => {
modelRequests.delete(requestKey);
});
modelRequests.set(requestKey, request);
return request;
}
async function resolveEffort(config: AiConfigItem, modelId: string, force = false): Promise<AiEffortCapability> {
const key = effortKey(config.id, modelId);
const signature = configSignature(config);
const current = effortCatalogs.get(key);
if (!force && current?.status === "ready" && current.signature === signature && current.capability && fresh(current.loadedAt)) {
return current.capability;
}
const requestKey = JSON.stringify([key, signature]);
const pending = effortRequests.get(requestKey);
if (pending) return pending;
const previousCapability = current?.signature === signature ? current.capability : undefined;
effortCatalogs.set(key, { status: "loading", capability: previousCapability, signature });
const request = api
.aiResolveModelEffort(configPayload(config, modelId), modelId)
.then((capability) => {
if (effortCatalogs.get(key)?.signature === signature) {
effortCatalogs.set(key, { status: "ready", capability, signature, loadedAt: Date.now() });
}
return capability;
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
if (effortCatalogs.get(key)?.signature === signature) {
effortCatalogs.set(key, { status: "error", capability: previousCapability, signature, error: message });
}
throw error;
})
.finally(() => {
effortRequests.delete(requestKey);
});
effortRequests.set(requestKey, request);
return request;
}
export function useAiModelCatalog() {
return {
catalogs,
effortCatalogs,
loadModels,
resolveEffort,
effortKey,
};
}