mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
37acde15d5
* fix(ai): defer Codex model discovery until a Codex session starts Opening any plan, annotate, or code review builds the shared AI runtime, and the runtime called every provider's fetchModels() while constructing itself. For the Codex provider that starts a throwaway `codex app-server` process, so a review launched Codex even when the user never opened Ask AI. On macOS with a quarantined Homebrew Codex payload this surfaces as a Gatekeeper confirmation dialog in front of the review the user actually asked for. Codex discovery now runs on explicit activation instead. The provider is still registered and still advertised through /api/ai/capabilities using its static fallback model metadata, so nothing about discovery is user-visible until a session is created for it. createBestEffortOnce() memoizes the discovery call so it runs at most once per runtime and a failure leaves the static fallback in place rather than blocking session creation. /api/ai/session gained a beforeProviderSession hook, invoked for the resolved provider id before the session is created. /api/ai/capabilities deliberately does not invoke it: the editor probes capabilities automatically on load, so activating a provider there would reintroduce the same eager launch through a different path. Because discovery can replace the provider's model list, the session handler compares the requested model against the pre-activation default. A caller that sent no model, or sent the pre-activation default, gets the post-activation default; an explicitly chosen model is always honored. Without this a first Codex session would pin the static fallback model that discovery just replaced. Both runtimes are changed the same way, and the other providers keep their existing eager discovery, which beforeCapabilities still awaits. Tests cover the regression with a fake Codex executable rather than a real one: runtime construction and a capabilities probe must not invoke discovery, the first Codex session must, the second must not, and a failing discovery must still create a session on the fallback metadata. * fix(ai): refresh provider metadata on explicit activation Follow-up to the deferred Codex discovery change, addressing the review findings on #1145 while keeping the deferral intact: constructing the runtime and probing /api/ai/capabilities still never spawns `codex app-server`. - /api/ai/capabilities now accepts ?activate=<providerId>: it runs the same createBestEffortOnce initializer the session path uses (no second discovery path) and responds with the refreshed capabilities payload. A plain capabilities probe still activates nothing. Both runtimes get this through the shared endpoint (packages/ai is vendored into the Pi server by vendor.sh). - The apps activate the selected provider on explicit user gestures -- opening the Ask AI surface or switching the provider picker -- via the new useAIProviderActivation hook (single-flight per provider id), then merge the refreshed models and reasoning efforts into state so the model picker and per-model reasoning-effort selector populate past the static fallback. (review finding 1) - A resolver-derived model is no longer persisted: useAIProviderConfig and AISettingsTab write the per-provider model preference only on an explicit user pick, so a saved Codex model the pre-activation fallback list doesn't include survives instead of being clobbered by the fallback id. The session request still falls back; the cookie doesn't. (review finding 2) - The session handler resolves the requested model by membership in the post-activation model list instead of comparing against the pre-activation default, so sessions after the first can no longer pin a stale fallback id that discovery already replaced. (review finding 3) Tests: activation endpoint behavior (shared endpoints plus both runtimes against a hermetic fake codex on PATH), effectiveModel membership resolution, and saved-preference no-clobber (DOM tests for useAIProviderConfig persistence). Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com>
147 lines
6.4 KiB
TypeScript
147 lines
6.4 KiB
TypeScript
import type React from 'react';
|
|
import { getProviderMeta } from './ProviderIcons';
|
|
import {
|
|
getAIProviderSettings,
|
|
resolveAIProviderSelection,
|
|
saveAIProviderSelection,
|
|
savePreferredModel,
|
|
type AIProviderOption,
|
|
} from '../utils/aiProvider';
|
|
import { useState } from 'react';
|
|
import type { Origin } from '@plannotator/core/agents';
|
|
|
|
interface AIProvider extends AIProviderOption {
|
|
capabilities: Record<string, boolean>;
|
|
}
|
|
|
|
interface AISettingsTabProps {
|
|
providers: AIProvider[];
|
|
selectedProviderId: string | null;
|
|
origin?: Origin | null;
|
|
onProviderChange: (providerId: string | null) => void;
|
|
}
|
|
|
|
export const AISettingsTab: React.FC<AISettingsTabProps> = ({
|
|
providers,
|
|
selectedProviderId,
|
|
origin,
|
|
onProviderChange,
|
|
}) => {
|
|
const settings = getAIProviderSettings();
|
|
const originDefault = resolveAIProviderSelection({ providers, origin, settings }).providerId;
|
|
const effectiveSelection = selectedProviderId ?? originDefault ?? providers[0]?.id ?? null;
|
|
const [preferredModels, setPreferredModels] = useState<Record<string, string>>(() =>
|
|
getAIProviderSettings().preferredModels
|
|
);
|
|
|
|
const handleSelectProvider = (providerId: string) => {
|
|
onProviderChange(providerId);
|
|
// Persist only the provider choice. Writing the resolved model here would
|
|
// clobber a saved preference the currently-advertised model list doesn't
|
|
// include (e.g. before deferred Codex discovery has run) with the static
|
|
// fallback id — the saved model only changes when the user picks one.
|
|
saveAIProviderSelection({ providerId, model: null, origin });
|
|
};
|
|
|
|
const handleModelChange = (providerId: string, modelId: string) => {
|
|
savePreferredModel(providerId, modelId);
|
|
setPreferredModels(prev => ({ ...prev, [providerId]: modelId }));
|
|
};
|
|
|
|
if (providers.length === 0) {
|
|
return (
|
|
<>
|
|
<div>
|
|
<div className="text-sm font-medium">AI Provider</div>
|
|
</div>
|
|
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg text-xs text-amber-600 dark:text-amber-400">
|
|
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<span>No AI providers detected. Install the <strong>claude</strong> or <strong>codex</strong> CLI and make sure you're authenticated. <a href="https://plannotator.ai/docs/guides/ai-features/" target="_blank" rel="noopener noreferrer" className="underline hover:text-amber-700 dark:hover:text-amber-300">Setup guide</a></span>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div>
|
|
<div className="text-sm font-medium">AI Provider</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Choose which AI provider and model to use for inline chat
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{providers.map((p) => {
|
|
const meta = getProviderMeta(p.name);
|
|
const Icon = meta.icon;
|
|
const isSelected = effectiveSelection === p.id;
|
|
const models = p.models ?? [];
|
|
const defaultModel = models.find(m => m.default) ?? models[0];
|
|
const selectedModel = preferredModels[p.id] ?? defaultModel?.id ?? null;
|
|
|
|
return (
|
|
<div key={p.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelectProvider(p.id)}
|
|
className={`w-full flex items-center gap-3 p-3 rounded-lg border transition-colors text-left ${
|
|
isSelected
|
|
? 'border-primary bg-primary/5'
|
|
: 'border-border hover:border-muted-foreground/30 hover:bg-muted/50'
|
|
} ${models.length > 1 && isSelected ? 'rounded-b-none border-b-0' : ''}`}
|
|
>
|
|
<div className="w-6 h-6 rounded-md bg-muted flex items-center justify-center text-muted-foreground">
|
|
<Icon className="w-4 h-4" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium">{meta.label}</div>
|
|
</div>
|
|
{isSelected && (
|
|
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
|
|
{/* Model selector — shown for selected provider when models available */}
|
|
{isSelected && models.length > 1 && (
|
|
<div className="border border-t-0 border-primary bg-primary/5 rounded-b-lg px-3 pb-3 pt-1">
|
|
<label className="flex items-center gap-2">
|
|
<span className="text-[11px] text-muted-foreground">Model</span>
|
|
<select
|
|
value={selectedModel ?? ''}
|
|
onChange={(e) => handleModelChange(p.id, e.target.value)}
|
|
className="flex-1 text-xs bg-muted rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
|
|
>
|
|
{models.map(m => (
|
|
<option key={m.id} value={m.id}>{m.label}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{/* Show current model as muted text for unselected providers */}
|
|
{!isSelected && models.length > 0 && (
|
|
<div className="px-3 -mt-0.5">
|
|
<span className="text-[10px] text-muted-foreground/50">
|
|
{models.find(m => m.id === (preferredModels[p.id] ?? defaultModel?.id))?.label ?? defaultModel?.label}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="text-[10px] text-muted-foreground/70">
|
|
Providers are detected from installed CLI tools. No API keys are managed by Plannotator — you must be authenticated with each CLI independently.{' '}
|
|
<a href="https://plannotator.ai/docs/guides/ai-features/" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Learn more</a>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|