blob: abe1a351d103e0c2e1bdbe378d5b86c7388250a6 [file]
import { useEffect, useMemo, useState } from 'react';
import type { LlmConnection, SessionSummary, SettingsSection, ThinkingLevel, UiLocale } from '@maka/core';
import { thinkingVariantsForModel } from '@maka/core';
import type { ChatModelChoice } from '@maka/ui';
import { deriveSessionHealthNotice } from './session-health-notice';
import { pickCatalogDefaultChatModel, pickNewChatModel } from './model-catalog-choices';
import { buildChatModelChoices, chatModelChoiceLabel, normalizeActiveChatModel } from './chat-model-selection';
import type { ComposerDefaults } from './composer-defaults';
import { getDesktopConversationCopy } from './locales/conversation-copy.js';
export type NewChatModel = { llmConnectionSlug: string; model: string };
export type SessionHealthNoticeView = {
tone: 'info' | 'warning' | 'destructive';
label: string;
tooltip?: string;
onClick(): void;
onClickTarget: 'models';
};
/**
* Owns every value the chat header + composer derive from the LLM-connection
* list and the active session: the resolved active connection/model labels,
* the shared model-choice list, the home / empty-state new-chat model + its
* sticky pick, the thinking-variant lists, and the hard-only session health
* notice (#1032).
*
* Pure move out of AppShell — every memo keeps its exact dependency array (so
* `chatModelChoices` / `activeThinkingLevels` / `newChatThinkingLevels` retain
* their referential-stability behavior) and the sticky-pick validation still
* drops a `pendingNewChatModel` that is no longer an offered choice. The
* `openSettingsSection` jump is injected so `sessionHealthNotice` can wrap the
* derived click target; its memo deliberately omits the injected handler from
* the dep array (see the inline note).
*/
export function useShellChatModel(options: {
uiLocale: UiLocale;
connections: LlmConnection[];
/**
* Refresh counter from `useShellConnections`: bumps on every successful
* `refreshConnections`, including credential-only changes that keep the
* list identity (`updatedAt` unchanged). The secret probe below depends
* on it so those changes re-probe instead of serving stale presence
* (#1038 review).
*/
connectionsRevision: number;
defaultConnection: string | null;
activationCandidate?: NewChatModel;
activeSession: SessionSummary | undefined;
/**
* True when the active session's loaded transcript already contains a
* user message. Storage self-heals `connectionLocked` only on
* `readHeader`/`readMessages`, so a just-opened legacy session's
* summary can still read unlocked; the loaded transcript is the same
* primary evidence storage uses, and the notice must not treat the
* session as rebindable in the meantime (#1038 review).
*/
activeSessionHasUserMessage: boolean;
persistedComposerDefaults: ComposerDefaults | null;
openSettingsSection: (section: SettingsSection) => void;
}): {
chatModelChoices: ChatModelChoice[];
activeConnection: LlmConnection | undefined;
activeConnectionLabel: string | undefined;
activeModel: string | undefined;
activeModelLabel: string | undefined;
activeThinkingLevels: readonly ThinkingLevel[];
activeThinkingLevel: ThinkingLevel | undefined;
newChatModel: NewChatModel | undefined;
newChatModelLabel: string | undefined;
newChatThinkingLevels: readonly ThinkingLevel[];
newChatThinkingLevel: ThinkingLevel | undefined;
pendingNewChatModel: NewChatModel | null;
setPendingNewChatModel: (next: NewChatModel | null) => void;
pendingNewChatThinkingLevel: ThinkingLevel | null;
setPendingNewChatThinkingLevel: (next: ThinkingLevel | null) => void;
sessionHealthNotice: SessionHealthNoticeView | undefined;
} {
const { uiLocale, connections, connectionsRevision, defaultConnection, activationCandidate, activeSession, activeSessionHasUserMessage, persistedComposerDefaults, openSettingsSection } = options;
const conversationCopy = getDesktopConversationCopy(uiLocale);
// Persisted composer defaults seed the empty-state model so the home view is
// populated before the async `app:info` round-trip completes on mount.
const [pendingNewChatModel, setPendingNewChatModel] = useState<NewChatModel | null>(
persistedComposerDefaults?.model ?? null,
);
const activeConnection = activeSession
? connections.find((connection) => connection.slug === activeSession.llmConnectionSlug)
: undefined;
const defaultConnectionEntry = defaultConnection
? connections.find((connection) => connection.slug === defaultConnection)
: undefined;
const chatModelChoices = useMemo<ChatModelChoice[]>(
() => buildChatModelChoices(connections),
[connections],
);
// Home / empty-state composer: which model the next NEW chat starts with.
// An explicit pick stays sticky; otherwise onboarding's readiness-checked
// candidate wins before the legacy catalog default and first offered choice.
// Renderer-only — it never mutates the persisted Settings · 模型 default.
const [pendingNewChatThinkingLevel, setPendingNewChatThinkingLevel] = useState<ThinkingLevel | null>(null);
// A pick only stays in effect while it is still an offered choice. If the user
// later disables/removes that connection or model, fall through to another
// offered candidate so the home chip never shows — nor sends — a stale model.
const catalogDefaultNewChatModel = defaultConnectionEntry
? pickCatalogDefaultChatModel(defaultConnectionEntry)
: undefined;
const newChatModel = pickNewChatModel({
pending: pendingNewChatModel,
activationCandidate,
catalogDefault: catalogDefaultNewChatModel,
choices: chatModelChoices,
});
const activeConnectionLabel = activeSession?.backend === 'fake'
? conversationCopy.model.fakeBackendLabel
: activeConnection?.name ?? activeSession?.llmConnectionSlug;
const activeModel = activeSession?.backend === 'fake'
? undefined
: normalizeActiveChatModel(activeSession, activeConnection, chatModelChoices);
const activeModelLabel = activeSession?.backend === 'fake'
? undefined
: chatModelChoiceLabel(chatModelChoices, activeSession?.llmConnectionSlug, activeModel);
const activeThinkingLevels = useMemo(
() => (activeConnection && activeModel) ? thinkingVariantsForModel(activeConnection.providerType, activeModel) : [],
[activeConnection, activeModel],
);
// Only surface a stored level when the current model still supports it;
// if the model changed (setModel clears it) or the catalog reconfigured so
// the level is no longer offered, the chip falls back to 默认 instead of
// advertising a level the runtime would silently drop. The runtime's
// `buildProviderOptions` is the wire-level guard; this keeps the UI honest.
const activeThinkingLevel =
activeSession?.thinkingLevel && activeThinkingLevels.includes(activeSession.thinkingLevel)
? activeSession.thinkingLevel
: undefined;
const newChatThinkingLevels = useMemo(
() => {
if (!newChatModel) return [];
const c = connections.find((entry) => entry.slug === newChatModel.llmConnectionSlug);
return c ? thinkingVariantsForModel(c.providerType, newChatModel.model) : [];
},
[newChatModel, connections],
);
const newChatThinkingLevel = pendingNewChatThinkingLevel && newChatThinkingLevels.includes(pendingNewChatThinkingLevel)
? pendingNewChatThinkingLevel
: undefined;
const newChatModelLabel = chatModelChoiceLabel(chatModelChoices, newChatModel?.llmConnectionSlug, newChatModel?.model);
// #1038: the notice decides from the same facts as the send gate, so
// the renderer needs real secret presence, not the old
// "default exists && enabled" proxy. Probe every connection's secret
// via IPC whenever the connection list changes (the list refreshes on
// every connection mutation, including API-key saves). Until the
// probe lands, presence is treated optimistically so a destructive
// notice never flashes on first paint.
const [secretPresence, setSecretPresence] = useState<Readonly<Record<string, boolean>>>({});
useEffect(() => {
let cancelled = false;
void Promise.all(
connections.map(async (connection) => {
try {
return [connection.slug, await window.maka.connections.hasSecret(connection.slug)] as const;
} catch {
return [connection.slug, true] as const;
}
}),
).then((entries) => {
if (!cancelled) setSecretPresence(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [connections, connectionsRevision]);
// Notice derivation is a pure function (see `session-health-notice.ts`); we
// wrap the returned `onClickTarget` here with the Settings-jump action.
const sessionHealthNotice = useMemo<SessionHealthNoticeView | undefined>(() => {
const derived = deriveSessionHealthNotice({
locale: uiLocale,
session: activeSession
? {
backend: activeSession.backend,
llmConnectionSlug: activeSession.llmConnectionSlug,
model: activeSession.model,
// Effective lock: the healed summary bit OR the same primary
// evidence storage heals from (a user message in the loaded
// transcript). See the option doc above.
connectionLocked: activeSession.connectionLocked || activeSessionHasUserMessage,
}
: undefined,
connections,
defaultSlug: defaultConnection,
hasSecret: (slug) => secretPresence[slug] ?? true,
lastTestStatus: activeConnection?.lastTestStatus,
});
if (!derived) return undefined;
const target = derived.onClickTarget;
return {
tone: derived.tone,
label: derived.label,
...(derived.tooltip ? { tooltip: derived.tooltip } : {}),
onClickTarget: target,
onClick: () => openSettingsSection(target),
};
// openSettingsSection is stable enough for our purposes — main.tsx
// doesn't depend on it changing, and including it would force the
// effect to re-create on every render due to its function identity.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeSession?.id,
activeSession?.backend,
activeSession?.llmConnectionSlug,
activeSession?.model,
activeSession?.connectionLocked,
activeSessionHasUserMessage,
connections,
defaultConnection,
secretPresence,
activeConnection?.lastTestStatus,
uiLocale,
]);
return {
chatModelChoices,
activeConnection,
activeConnectionLabel,
activeModel,
activeModelLabel,
activeThinkingLevels,
activeThinkingLevel,
newChatModel,
newChatModelLabel,
newChatThinkingLevels,
newChatThinkingLevel,
pendingNewChatModel,
setPendingNewChatModel,
pendingNewChatThinkingLevel,
setPendingNewChatThinkingLevel,
sessionHealthNotice,
};
}