ci/woodpecker/push/publish Pipeline failed
Co-authored-by: shaggy <[email protected]>
209 lines
7.7 KiB
TypeScript
209 lines
7.7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
fetchCatalog,
|
|
fetchHarnesses,
|
|
fetchPersistedSelection,
|
|
persistSelection,
|
|
type SelectionErrorCode,
|
|
} from './chat-api';
|
|
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
|
|
|
export interface HarnessPersistError {
|
|
code: SelectionErrorCode;
|
|
message: string;
|
|
/** The exact tuple the user requested — preserved so the failed selection
|
|
* stays visible rather than being silently dropped. */
|
|
requested: HarnessSelection;
|
|
}
|
|
|
|
export interface HarnessSelectionValue {
|
|
harnesses: HarnessSummary[];
|
|
catalog: HarnessCatalog | null;
|
|
/** True when the selected harness has no usable catalog (404/error). */
|
|
catalogUnavailable: boolean;
|
|
/** The working (displayed) selection, kept as three distinct ids. Empty
|
|
* strings mean "not chosen yet" — there is deliberately no first-row default. */
|
|
harnessId: string;
|
|
providerId: string;
|
|
modelId: string;
|
|
/** The last tuple confirmed persisted by the server, or null. */
|
|
persistedSelection: HarnessSelection | null;
|
|
/** True when a persisted selection references a model no longer present as an
|
|
* available catalog entry — it stays visibly displayed rather than dropped. */
|
|
isStale: boolean;
|
|
/** True ONLY once a full tuple has been confirmed persisted AND it is a
|
|
* currently-available catalog entry. Send stays disabled otherwise, so a send
|
|
* can never race ahead of successful persistence. */
|
|
canSend: boolean;
|
|
persistError: HarnessPersistError | null;
|
|
selectHarness: (harnessId: string) => void;
|
|
selectProvider: (providerId: string) => void;
|
|
/** Persist the EXACT catalog row's `{providerId, modelId}` — the caller
|
|
* resolves the composite option identity to the real entry and passes both
|
|
* ids, so a bare model id is never combined with ambient provider state. */
|
|
selectModel: (providerId: string, modelId: string) => void;
|
|
}
|
|
|
|
/** A tuple is a currently-usable catalog option only when the catalog holds a
|
|
* matching, available entry — the single gate that keeps a stale/unavailable
|
|
* model from ever counting as sendable. */
|
|
function isAvailableInCatalog(
|
|
selection: HarnessSelection | null,
|
|
catalog: HarnessCatalog | null,
|
|
): boolean {
|
|
if (selection === null || catalog === null) return false;
|
|
return catalog.models.some(
|
|
(model) =>
|
|
model.providerId === selection.providerId &&
|
|
model.modelId === selection.modelId &&
|
|
model.availability === 'available',
|
|
);
|
|
}
|
|
|
|
function tuplesEqual(a: HarnessSelection | null, b: HarnessSelection | null): boolean {
|
|
if (a === null || b === null) return a === b;
|
|
return a.harnessId === b.harnessId && a.providerId === b.providerId && a.modelId === b.modelId;
|
|
}
|
|
|
|
/**
|
|
* Owns the harness/catalog/selection state for the chat composer: loads the
|
|
* harness list and any persisted tuple on mount, loads a harness's catalog when
|
|
* chosen, and PUT-persists the full `{harnessId, providerId, modelId}` tuple
|
|
* when a model is picked. It never auto-selects a catalog row, keeps a
|
|
* stale/unavailable persisted tuple visible, and only reports `canSend` true
|
|
* once a full tuple has actually persisted as an available catalog entry.
|
|
*/
|
|
export function useHarnessSelection(): HarnessSelectionValue {
|
|
const [harnesses, setHarnesses] = useState<HarnessSummary[]>([]);
|
|
const [catalog, setCatalog] = useState<HarnessCatalog | null>(null);
|
|
const [catalogUnavailable, setCatalogUnavailable] = useState(false);
|
|
const [harnessId, setHarnessId] = useState('');
|
|
const [providerId, setProviderId] = useState('');
|
|
const [modelId, setModelId] = useState('');
|
|
const [persistedSelection, setPersistedSelection] = useState<HarnessSelection | null>(null);
|
|
const [persistError, setPersistError] = useState<HarnessPersistError | null>(null);
|
|
|
|
// Monotonic request ids so a slow in-flight catalog/persist response can never
|
|
// overwrite the result of a newer request the user has since triggered.
|
|
const catalogRequestRef = useRef(0);
|
|
const persistRequestRef = useRef(0);
|
|
|
|
const loadCatalog = useCallback(async (id: string): Promise<void> => {
|
|
const requestId = catalogRequestRef.current + 1;
|
|
catalogRequestRef.current = requestId;
|
|
setCatalog(null);
|
|
setCatalogUnavailable(false);
|
|
const result = await fetchCatalog(id);
|
|
if (catalogRequestRef.current !== requestId) return;
|
|
if (result.ok) {
|
|
setCatalog(result.catalog);
|
|
setCatalogUnavailable(false);
|
|
} else {
|
|
setCatalog(null);
|
|
setCatalogUnavailable(true);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
void (async (): Promise<void> => {
|
|
const [list, persisted] = await Promise.all([fetchHarnesses(), fetchPersistedSelection()]);
|
|
if (!active) return;
|
|
setHarnesses(list);
|
|
if (persisted !== null) {
|
|
// Adopt the persisted tuple as the displayed selection and load its
|
|
// catalog. If the model has since been retired, it still shows (stale).
|
|
setHarnessId(persisted.harnessId);
|
|
setProviderId(persisted.providerId);
|
|
setModelId(persisted.modelId);
|
|
setPersistedSelection(persisted);
|
|
await loadCatalog(persisted.harnessId);
|
|
}
|
|
// No persisted selection → nothing is auto-selected; the user must choose.
|
|
})();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [loadCatalog]);
|
|
|
|
const selectHarness = useCallback(
|
|
(id: string): void => {
|
|
setHarnessId(id);
|
|
// Changing harness invalidates the provider/model draft — never carry a
|
|
// model across harnesses.
|
|
setProviderId('');
|
|
setModelId('');
|
|
setPersistError(null);
|
|
void loadCatalog(id);
|
|
},
|
|
[loadCatalog],
|
|
);
|
|
|
|
const selectProvider = useCallback((id: string): void => {
|
|
setProviderId(id);
|
|
// A new provider invalidates the chosen model — no cross-provider carryover.
|
|
setModelId('');
|
|
setPersistError(null);
|
|
}, []);
|
|
|
|
const selectModel = useCallback(
|
|
(selectedProviderId: string, selectedModelId: string): void => {
|
|
// Bind the model to the EXACT catalog row's provider — never to ambient
|
|
// provider state — so two providers exposing the same modelId can never
|
|
// collide or mis-resolve. Keep the displayed provider consistent with the
|
|
// resolved row.
|
|
setProviderId(selectedProviderId);
|
|
setModelId(selectedModelId);
|
|
setPersistError(null);
|
|
const requested: HarnessSelection = {
|
|
harnessId,
|
|
providerId: selectedProviderId,
|
|
modelId: selectedModelId,
|
|
};
|
|
const requestId = persistRequestRef.current + 1;
|
|
persistRequestRef.current = requestId;
|
|
void (async (): Promise<void> => {
|
|
const result = await persistSelection(requested);
|
|
if (persistRequestRef.current !== requestId) return;
|
|
if (result.ok) {
|
|
setPersistedSelection(result.selection);
|
|
setPersistError(null);
|
|
} else {
|
|
// Leave persistedSelection unchanged (send stays disabled) and surface
|
|
// the typed error carrying the exact requested tuple.
|
|
setPersistError({
|
|
code: result.code,
|
|
message: result.message,
|
|
requested: result.requested,
|
|
});
|
|
}
|
|
})();
|
|
},
|
|
[harnessId],
|
|
);
|
|
|
|
const draft: HarnessSelection = { harnessId, providerId, modelId };
|
|
const isStale = persistedSelection !== null && !isAvailableInCatalog(persistedSelection, catalog);
|
|
const canSend =
|
|
persistedSelection !== null &&
|
|
!catalogUnavailable &&
|
|
tuplesEqual(draft, persistedSelection) &&
|
|
isAvailableInCatalog(persistedSelection, catalog);
|
|
|
|
return {
|
|
harnesses,
|
|
catalog,
|
|
catalogUnavailable,
|
|
harnessId,
|
|
providerId,
|
|
modelId,
|
|
persistedSelection,
|
|
isStale,
|
|
canSend,
|
|
persistError,
|
|
selectHarness,
|
|
selectProvider,
|
|
selectModel,
|
|
};
|
|
}
|