mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 08:42:45 +00:00
The list-models pipeline already served LKG on failure; this wires the rest of the LKG-first, manual-refresh policy end to end. Main: the list-models IPC accepts a force flag — manual refresh always skips the in-memory cache and the persisted entry and goes live, and a forced success overwrites the persisted catalog. Renderer: the panel passes force only from the retry banner and the picker's refresh button; panel-visibility and send-preflight loads stay non-force and replay the renderer cache, so with a persisted catalog main answers them without spending the live-fetch budget. Picker: a footer refresh icon-button next to the status chip, visible whenever any fetched catalog (live or last-known-good) or the degraded warning is present; the spinner row remains the in-flight feedback. Tooltip localized as ai.chat.refreshModelList in en/es/ru/zh-CN/zh-TW. Fresh installs are unchanged (no persisted entry → live fetch as before); existing users stop seeing jitter warnings on panel open and pick up new CLI models via the refresh button.
447 lines
18 KiB
TypeScript
447 lines
18 KiB
TypeScript
import React, { type Dispatch, type SetStateAction } from 'react';
|
|
import { History, Plus, Undo2 } from 'lucide-react';
|
|
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
|
|
import type { Host, VaultNote } from '../types';
|
|
import type { UserSkillOption } from './ai/userSkillsState';
|
|
import type { AIQuickMessage } from '../infrastructure/ai/quickMessages';
|
|
import { Button } from './ui/button';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
|
import AgentSelector from './ai/AgentSelector';
|
|
import ChatInput from './ai/ChatInput';
|
|
import ChatMessageList from './ai/ChatMessageList';
|
|
import ConversationExport from './ai/ConversationExport';
|
|
import { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
|
|
import { cn } from '../lib/utils';
|
|
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from './terminalLayer/terminalSidePanelChrome';
|
|
import {
|
|
getAIPanelDiagnosticHiddenParts,
|
|
getAIPanelProfilerProps,
|
|
isAIPanelDiagnosticPartHidden,
|
|
} from './ai/aiPanelDiagnostics';
|
|
|
|
type Translate = (key: string) => string;
|
|
type ExportFormat = 'md' | 'json' | 'txt';
|
|
type TerminalSessionSummary = {
|
|
sessionId: string;
|
|
hostname: string;
|
|
label: string;
|
|
connected: boolean;
|
|
};
|
|
|
|
interface AIChatPanelContentProps {
|
|
t: Translate;
|
|
currentAgentId: string;
|
|
externalAgents: ExternalAgentConfig[];
|
|
discoveredAgents: DiscoveredAgent[];
|
|
isDiscovering: boolean;
|
|
handleAgentChange: (agentId: string) => void;
|
|
handleEnableDiscoveredAgent: (agent: DiscoveredAgent) => void;
|
|
rediscover: () => void;
|
|
handleOpenSettings: () => void;
|
|
activeSession: AISession | null;
|
|
handleExport: (format: ExportFormat) => void;
|
|
showHistory: boolean;
|
|
setShowHistory: Dispatch<SetStateAction<boolean>>;
|
|
handleNewChat: () => void;
|
|
historySessions: AISession[];
|
|
activeSessionId: string | null;
|
|
handleSelectSession: (sessionId: string) => void;
|
|
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
|
|
/** Fork the conversation up to a completed assistant response into a new session. */
|
|
onForkFromMessage?: (messageId: string) => void;
|
|
messages: ChatMessage[];
|
|
isStreaming: boolean;
|
|
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
|
|
contextUsage?: import('../application/state/useAgentCompactionUi').AgentContextUsage | null;
|
|
inputValue: string;
|
|
setInputValue: (value: string) => void;
|
|
handleSend: () => void;
|
|
handleCompact: () => void;
|
|
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
|
|
canUndoLastTurn?: boolean;
|
|
handleUndoLastTurn?: () => void;
|
|
canCompact?: boolean;
|
|
handleSteer: () => void;
|
|
handleStop: () => void;
|
|
canSteer: boolean;
|
|
isSteering: boolean;
|
|
steerWarning?: string;
|
|
lockTurnConfiguration: boolean;
|
|
canSendCurrentAgent: boolean;
|
|
providerDisplayName?: string;
|
|
modelDisplayName?: string;
|
|
modelCatalogWarning?: string;
|
|
/** Live SDK model catalog is loading (no built-in presets to show yet). */
|
|
modelCatalogLoading?: boolean;
|
|
/** Provenance of the active model list, rendered as the picker status chip. */
|
|
modelCatalogSource?: 'live' | 'last-known-good' | 'presets';
|
|
/** ISO timestamp of the successful fetch backing `modelCatalogSource`. */
|
|
modelCatalogFetchedAt?: string;
|
|
/** Retry a failed SDK model catalog load from the warning banner. */
|
|
handleModelCatalogRetry?: () => void;
|
|
agentModelPresets: AgentModelPreset[];
|
|
/** Suppress the composer picker's "use custom model" action (locked model). */
|
|
allowCustomModelEntry?: boolean;
|
|
selectedAgentModel: string;
|
|
handleAgentModelSelect: (modelId: string) => void;
|
|
cattyConfiguredProviders: ProviderConfig[];
|
|
effectiveActiveProvider?: ProviderConfig;
|
|
effectiveActiveModelId?: string;
|
|
handleAgentProviderModelSelect: (providerId: string, modelId: string, contextWindow?: number) => void;
|
|
selectedCattyThinking?: string;
|
|
handleCattyThinkingSelect?: (level: string) => void;
|
|
files: UploadedFile[];
|
|
addFiles: (inputFiles: File[]) => Promise<void>;
|
|
removeFile: (fileId: string) => void;
|
|
terminalSessions: TerminalSessionSummary[];
|
|
selectedUserSkills: UserSkillOption[];
|
|
userSkillOptions: UserSkillOption[];
|
|
quickMessages: AIQuickMessage[];
|
|
addSelectedUserSkill: (slug: string) => void;
|
|
removeSelectedUserSkill: (slug: string) => void;
|
|
globalPermissionMode: AIPermissionMode;
|
|
setGlobalPermissionMode?: (mode: AIPermissionMode) => void;
|
|
notes?: VaultNote[];
|
|
hosts?: Host[];
|
|
/** Mention Note: attach a Vault → Notes entry as inline context. */
|
|
onMentionNote?: (note: VaultNote) => void;
|
|
onOpenVaultNote?: (noteId: string) => void;
|
|
onOpenVaultHost?: (hostId: string) => void;
|
|
onOpenVaultSection?: (section: 'notes' | 'hosts') => void;
|
|
/** Hidden retained panels keep the composer warm without the message tree. */
|
|
parked?: boolean;
|
|
/** Disable header transitions while send preflight is in flight. */
|
|
sending?: boolean;
|
|
}
|
|
|
|
export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
|
t,
|
|
currentAgentId,
|
|
externalAgents,
|
|
discoveredAgents,
|
|
isDiscovering,
|
|
handleAgentChange,
|
|
handleEnableDiscoveredAgent,
|
|
rediscover,
|
|
handleOpenSettings,
|
|
activeSession,
|
|
handleExport,
|
|
showHistory,
|
|
setShowHistory,
|
|
handleNewChat,
|
|
historySessions,
|
|
activeSessionId,
|
|
handleSelectSession,
|
|
handleDeleteSession,
|
|
onForkFromMessage,
|
|
messages,
|
|
isStreaming,
|
|
activeCompaction = null,
|
|
contextUsage = null,
|
|
inputValue,
|
|
setInputValue,
|
|
handleSend,
|
|
handleCompact,
|
|
canUndoLastTurn = false,
|
|
handleUndoLastTurn,
|
|
canCompact = false,
|
|
handleSteer,
|
|
handleStop,
|
|
handleModelCatalogRetry,
|
|
canSteer,
|
|
isSteering,
|
|
steerWarning,
|
|
lockTurnConfiguration,
|
|
canSendCurrentAgent,
|
|
providerDisplayName,
|
|
modelDisplayName,
|
|
modelCatalogWarning,
|
|
modelCatalogLoading = false,
|
|
modelCatalogSource,
|
|
modelCatalogFetchedAt,
|
|
agentModelPresets,
|
|
allowCustomModelEntry = true,
|
|
selectedAgentModel,
|
|
handleAgentModelSelect,
|
|
cattyConfiguredProviders,
|
|
effectiveActiveProvider,
|
|
effectiveActiveModelId,
|
|
handleAgentProviderModelSelect,
|
|
selectedCattyThinking,
|
|
handleCattyThinkingSelect,
|
|
files,
|
|
addFiles,
|
|
removeFile,
|
|
terminalSessions,
|
|
selectedUserSkills,
|
|
userSkillOptions,
|
|
quickMessages,
|
|
addSelectedUserSkill,
|
|
removeSelectedUserSkill,
|
|
globalPermissionMode,
|
|
setGlobalPermissionMode,
|
|
notes = [],
|
|
hosts = [],
|
|
onMentionNote,
|
|
onOpenVaultNote,
|
|
onOpenVaultHost,
|
|
onOpenVaultSection,
|
|
parked = false,
|
|
sending = false,
|
|
}) => {
|
|
const mentionHosts = React.useMemo(
|
|
() => terminalSessions.map((session) => ({
|
|
sessionId: session.sessionId,
|
|
hostname: session.hostname,
|
|
label: session.label,
|
|
connected: session.connected,
|
|
})),
|
|
[terminalSessions],
|
|
);
|
|
const providerSwitcher = React.useMemo(
|
|
() => (
|
|
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
|
|
? {
|
|
providers: cattyConfiguredProviders,
|
|
selectedProviderId: effectiveActiveProvider?.id,
|
|
selectedModelId: effectiveActiveModelId || undefined,
|
|
onSelect: handleAgentProviderModelSelect,
|
|
}
|
|
: undefined
|
|
),
|
|
[
|
|
cattyConfiguredProviders,
|
|
currentAgentId,
|
|
effectiveActiveModelId,
|
|
effectiveActiveProvider?.id,
|
|
handleAgentProviderModelSelect,
|
|
],
|
|
);
|
|
const hiddenParts = getAIPanelDiagnosticHiddenParts();
|
|
const hideHeader = isAIPanelDiagnosticPartHidden('header', hiddenParts);
|
|
const hideHistory = isAIPanelDiagnosticPartHidden('history', hiddenParts);
|
|
const hideMessages = isAIPanelDiagnosticPartHidden('messages', hiddenParts);
|
|
const hideRecent = isAIPanelDiagnosticPartHidden('recent', hiddenParts);
|
|
const hideInput = isAIPanelDiagnosticPartHidden('input', hiddenParts);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
|
|
{/* ── Header ── */}
|
|
{!hideHeader && (
|
|
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Header')}>
|
|
<div className={cn(
|
|
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
|
'px-2 flex items-center justify-between border-b border-border/50',
|
|
)}>
|
|
<AgentSelector
|
|
currentAgentId={currentAgentId}
|
|
externalAgents={externalAgents}
|
|
discoveredAgents={discoveredAgents}
|
|
isDiscovering={isDiscovering}
|
|
parked={parked}
|
|
disabled={sending}
|
|
onSelectAgent={handleAgentChange}
|
|
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
|
|
onRediscover={rediscover}
|
|
onManageAgents={handleOpenSettings}
|
|
/>
|
|
<div className="flex items-center gap-0.5">
|
|
{handleUndoLastTurn && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
|
aria-label={t('ai.chat.undoLastTurn')}
|
|
disabled={sending || isStreaming || !canUndoLastTurn}
|
|
onClick={handleUndoLastTurn}
|
|
>
|
|
<Undo2 size={12} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<ConversationExport
|
|
session={activeSession}
|
|
onExport={handleExport}
|
|
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground [&_svg]:size-3"
|
|
/>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
|
disabled={sending}
|
|
onClick={() => setShowHistory(!showHistory)}
|
|
>
|
|
<History size={12} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-6 w-6 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
|
|
disabled={sending}
|
|
onClick={handleNewChat}
|
|
>
|
|
<Plus size={13} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</React.Profiler>
|
|
)}
|
|
|
|
{/* ── Main content ── */}
|
|
{!parked && showHistory && !hideHistory ? (
|
|
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.History')}>
|
|
<SessionHistoryDrawer
|
|
sessions={historySessions}
|
|
activeSessionId={activeSessionId}
|
|
onSelect={sending ? () => undefined : handleSelectSession}
|
|
onDelete={handleDeleteSession}
|
|
onClose={() => setShowHistory(false)}
|
|
/>
|
|
</React.Profiler>
|
|
) : (
|
|
<>
|
|
{/* Chat messages */}
|
|
{!parked && !hideMessages && (
|
|
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Messages')}>
|
|
<ChatMessageList
|
|
messages={messages}
|
|
isStreaming={isStreaming}
|
|
activeSessionId={activeSessionId}
|
|
activeCompaction={activeCompaction}
|
|
contextCompaction={activeSession?.contextCompaction}
|
|
onForkFromMessage={onForkFromMessage}
|
|
notes={notes}
|
|
hosts={hosts}
|
|
onOpenVaultNote={onOpenVaultNote}
|
|
onOpenVaultHost={onOpenVaultHost}
|
|
onOpenVaultSection={onOpenVaultSection}
|
|
/>
|
|
</React.Profiler>
|
|
)}
|
|
|
|
{/* Recent sessions (Zed-style, shown when no messages) */}
|
|
{!parked && messages.length === 0 && historySessions.length > 0 && !hideRecent && (
|
|
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Recent')}>
|
|
<div className="shrink-0 px-4 pb-1">
|
|
<div className="flex items-baseline justify-between mb-2">
|
|
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
|
|
<button
|
|
onClick={() => setShowHistory(true)}
|
|
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
|
|
>
|
|
{t('ai.chat.viewAll')}
|
|
</button>
|
|
</div>
|
|
{historySessions.slice(0, 3).map((session) => (
|
|
<button
|
|
key={session.id}
|
|
disabled={sending}
|
|
onClick={() => handleSelectSession(session.id)}
|
|
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer disabled:pointer-events-none disabled:opacity-50"
|
|
>
|
|
<span className="text-[13px] text-foreground/60 truncate pr-4">
|
|
{session.title || t('ai.chat.untitled')}
|
|
</span>
|
|
<span className="text-[11px] text-muted-foreground/25 shrink-0">
|
|
{formatRelativeTime(new Date(session.updatedAt), t)}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</React.Profiler>
|
|
)}
|
|
|
|
{/* Input area */}
|
|
{!hideInput && (
|
|
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
|
|
<div>
|
|
{steerWarning ? (
|
|
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
|
{steerWarning}
|
|
</div>
|
|
) : modelCatalogWarning ? (
|
|
<div role="status" className="mx-3 mb-1.5 flex items-center gap-2 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
|
|
<span className="min-w-0 flex-1">{modelCatalogWarning}</span>
|
|
{handleModelCatalogRetry && (
|
|
<button
|
|
onClick={handleModelCatalogRetry}
|
|
className="shrink-0 rounded px-1.5 py-0.5 text-[11px] font-medium text-amber-600 underline-offset-2 transition-colors hover:bg-amber-500/10 hover:text-amber-500 hover:underline cursor-pointer dark:text-amber-400 dark:hover:text-amber-300"
|
|
>
|
|
{t('ai.chat.modelCatalogRetry')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : modelCatalogLoading ? (
|
|
<div role="status" className="mx-3 mb-1.5 rounded-md bg-muted/40 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground/70">
|
|
{t('ai.chat.loadingModels')}
|
|
</div>
|
|
) : null}
|
|
<ChatInput
|
|
parked={parked}
|
|
value={inputValue}
|
|
onChange={setInputValue}
|
|
onSend={handleSend}
|
|
onCompact={handleCompact}
|
|
canCompact={canCompact}
|
|
onSteer={handleSteer}
|
|
onStop={handleStop}
|
|
isStreaming={isStreaming}
|
|
canSteer={canSteer}
|
|
isSteering={isSteering}
|
|
lockTurnConfiguration={lockTurnConfiguration}
|
|
disabled={!canSendCurrentAgent}
|
|
providerName={providerDisplayName}
|
|
modelName={modelDisplayName}
|
|
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
|
|
modelPresets={agentModelPresets}
|
|
modelCatalogLoading={modelCatalogLoading}
|
|
modelCatalogSource={modelCatalogSource}
|
|
modelCatalogFetchedAt={modelCatalogFetchedAt}
|
|
modelCatalogWarning={modelCatalogWarning}
|
|
onRefreshModelCatalog={handleModelCatalogRetry}
|
|
allowCustomModelEntry={allowCustomModelEntry}
|
|
selectedModelId={selectedAgentModel}
|
|
onModelSelect={handleAgentModelSelect}
|
|
providerSwitcher={providerSwitcher}
|
|
pickerScope={currentAgentId}
|
|
thinkingLevel={currentAgentId === 'catty' ? selectedCattyThinking : undefined}
|
|
onThinkingLevelChange={currentAgentId === 'catty' ? handleCattyThinkingSelect : undefined}
|
|
files={files}
|
|
onAddFiles={addFiles}
|
|
onRemoveFile={removeFile}
|
|
hosts={mentionHosts}
|
|
notes={notes}
|
|
onMentionNote={onMentionNote}
|
|
selectedUserSkills={selectedUserSkills}
|
|
userSkills={userSkillOptions}
|
|
quickMessages={quickMessages}
|
|
onAddUserSkill={addSelectedUserSkill}
|
|
onRemoveUserSkill={removeSelectedUserSkill}
|
|
permissionMode={globalPermissionMode}
|
|
onPermissionModeChange={setGlobalPermissionMode}
|
|
contextUsage={contextUsage}
|
|
/>
|
|
</div>
|
|
</React.Profiler>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
};
|