mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
The response-timeout setting reached the briefing, the status notes and the Coach, which threaded it by hand. Document reads, document extraction, lab OCR and lab staging called the provider without a timeout, so every client fell back to its own 60 s literal and a slow self-hosted model was cut off however high the setting stood. The setting now rides on the provider instance. resolveProvider, resolveProviderChain and resolveProviderForTest stamp the record's aiResponseTimeoutSeconds on everything they return, and each wire client reads its ceiling through callTimeoutMs: the setting, else the surface's own timeoutMs, else 60 s. A call site no longer has to pass it, so a new one cannot forget it. The connection test runs under the same ceiling a read does. The Coach nudge tick and the reaction line keep their short ceilings (timeoutPolicy: "surface-ceiling"): the tick runs accounts one after another under a shared budget, the reaction line's claim lease is shorter than the largest setting, and both have deterministic copy that stands in. /api/auth/me publishes the effective value as ai.provider.responseTimeoutMs, so a client can size its own request timeout from it instead of guessing. Refs #1090
113 lines
4.0 KiB
TypeScript
113 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
|
|
import type {
|
|
AiCapabilityKey,
|
|
AiCapabilityState,
|
|
AiProviderState,
|
|
} from "@/lib/ai/capabilities/types";
|
|
import { PROVIDER_DEFAULT_TIMEOUT_MS } from "@/lib/ai/effective-timeout";
|
|
|
|
/**
|
|
* The web reader for the `ai` block on `GET /api/auth/me`.
|
|
*
|
|
* The server resolves every AI capability for the record this browser is
|
|
* inside, from every layer that can say no, and publishes the answer with the
|
|
* reason. This hook hands that answer to a surface and adds exactly one rule
|
|
* of its own: until the answer is known, a capability is unavailable. While
|
|
* `/me` is loading, when no query client is mounted (an isolated
|
|
* presentational render), or when the payload carries no well-formed block,
|
|
* the hook answers `check_failed`, so no AI surface paints and no model
|
|
* request fires on first render only to be refused. Failing open was how AI
|
|
* chrome used to flash and fire refusals before the switch set arrived.
|
|
*
|
|
* Never recompute a capability here or in a component; branch on
|
|
* `available`, and on `reason` only to say why.
|
|
*/
|
|
const UNKNOWN: AiCapabilityState = Object.freeze({
|
|
available: false,
|
|
reason: "check_failed",
|
|
onDeviceAllowed: false,
|
|
});
|
|
|
|
const NO_PROVIDER_STATE: AiProviderState = Object.freeze({
|
|
configured: false,
|
|
managedBy: null,
|
|
canConfigure: false,
|
|
responseTimeoutMs: PROVIDER_DEFAULT_TIMEOUT_MS,
|
|
});
|
|
|
|
/** One capability, for the record this browser is inside. */
|
|
export function useAiCapability(key: AiCapabilityKey): AiCapabilityState {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return UNKNOWN;
|
|
// The branch is stable for the component's lifetime (a provider is either
|
|
// mounted around it or not), so the conditional hook call is safe.
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiCapabilityInner(key);
|
|
}
|
|
|
|
function useAiCapabilityInner(key: AiCapabilityKey): AiCapabilityState {
|
|
const { user } = useAuth();
|
|
return user?.ai?.capabilities?.[key] ?? UNKNOWN;
|
|
}
|
|
|
|
/** The account's provider state: configured, managed by whom, configurable here. */
|
|
export function useAiProviderState(): AiProviderState {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return NO_PROVIDER_STATE;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiProviderStateInner();
|
|
}
|
|
|
|
function useAiProviderStateInner(): AiProviderState {
|
|
const { user } = useAuth();
|
|
return user?.ai?.provider ?? NO_PROVIDER_STATE;
|
|
}
|
|
|
|
/**
|
|
* The server's answer for one capability, or `null` while there is none yet
|
|
* (`/me` loading, no signed-in account, no query client). For the few places
|
|
* that act on an unavailable capability rather than just hiding: a Coach page
|
|
* that sends its visitor elsewhere must not do so on the loading frame, when
|
|
* `useAiCapability` already reads unavailable.
|
|
*/
|
|
export function useAiCapabilityAnswer(
|
|
key: AiCapabilityKey,
|
|
): AiCapabilityState | null {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return null;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiCapabilityAnswerInner(key);
|
|
}
|
|
|
|
function useAiCapabilityAnswerInner(
|
|
key: AiCapabilityKey,
|
|
): AiCapabilityState | null {
|
|
const { user, isLoading } = useAuth();
|
|
if (isLoading || !user) return null;
|
|
return user.ai?.capabilities?.[key] ?? UNKNOWN;
|
|
}
|
|
|
|
/**
|
|
* Every capability at once, or `null` until `/me` has answered. For the one
|
|
* surface that explains the whole set (the operator notice in Settings → AI);
|
|
* a surface that shows or hides one feature reads `useAiCapability`.
|
|
*/
|
|
export function useAiCapabilityMap(): Partial<
|
|
Record<AiCapabilityKey, AiCapabilityState>
|
|
> | null {
|
|
const hasClient = useQueryClientMounted();
|
|
if (!hasClient) return null;
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
return useAiCapabilityMapInner();
|
|
}
|
|
|
|
function useAiCapabilityMapInner(): Partial<
|
|
Record<AiCapabilityKey, AiCapabilityState>
|
|
> | null {
|
|
const { user } = useAuth();
|
|
return user?.ai?.capabilities ?? null;
|
|
}
|