mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
feat(frontend): Update AI chat for native streaming architecture
Adapts frontend to work with the new native chat service: Model selector improvements: - Extract ModelSelector to reusable component - Add notable model filtering (shows recent models by default) - Add "Show older models" toggle for legacy model access - Add notable badge indicator for recommended models Chat flow changes: - Simplify approval handling - backend agentic loop executes commands - Tool results now come via stream events, not approval response - Session model selection preserved when switching sessions Type updates: - Add 'notable' field to ModelInfo interface - Add 'notable' to API response types
This commit is contained in:
@@ -56,8 +56,8 @@ export class AIAPI {
|
||||
}
|
||||
|
||||
// Get available models from the AI provider
|
||||
static async getModels(): Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }> {
|
||||
return apiFetchJSON(`${this.baseUrl}/ai/models`) as Promise<{ models: { id: string; name: string; description?: string }[]; error?: string }>;
|
||||
static async getModels(): Promise<{ models: { id: string; name: string; description?: string; notable?: boolean }[]; error?: string }> {
|
||||
return apiFetchJSON(`${this.baseUrl}/ai/models`) as Promise<{ models: { id: string; name: string; description?: string; notable?: boolean }[]; error?: string }>;
|
||||
}
|
||||
|
||||
// Get AI cost/usage summary
|
||||
|
||||
@@ -23,6 +23,23 @@ interface ChatHeaderProps {
|
||||
export const ChatHeader: Component<ChatHeaderProps> = (props) => {
|
||||
const [showModelSelector, setShowModelSelector] = createSignal(false);
|
||||
const [showSessionPicker, setShowSessionPicker] = createSignal(false);
|
||||
const [showAllModels, setShowAllModels] = createSignal(false);
|
||||
|
||||
// Filter models based on notable status
|
||||
const filteredModels = () => {
|
||||
if (showAllModels()) {
|
||||
return props.models;
|
||||
}
|
||||
// Show notable models, or all if none are notable
|
||||
const notable = props.models.filter(m => m.notable);
|
||||
return notable.length > 0 ? notable : props.models;
|
||||
};
|
||||
|
||||
// Count hidden models
|
||||
const hiddenModelCount = () => {
|
||||
const notable = props.models.filter(m => m.notable);
|
||||
return props.models.length - notable.length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-900/20 dark:to-violet-900/20">
|
||||
@@ -82,7 +99,7 @@ export const ChatHeader: Component<ChatHeaderProps> = (props) => {
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">Default</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Use configured default model</div>
|
||||
</button>
|
||||
<For each={Array.from(groupModelsByProvider(props.models).entries())}>
|
||||
<For each={Array.from(groupModelsByProvider(filteredModels()).entries())}>
|
||||
{([provider, models]) => (
|
||||
<>
|
||||
<div class="px-3 py-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 sticky top-0">
|
||||
@@ -94,8 +111,15 @@ export const ChatHeader: Component<ChatHeaderProps> = (props) => {
|
||||
onClick={() => { props.onModelChange(model.id); setShowModelSelector(false); }}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700 ${props.selectedModel === model.id ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{model.name || model.id.split(':').pop()}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{model.name || model.id.split(':').pop()}
|
||||
</span>
|
||||
<Show when={model.notable}>
|
||||
<span class="px-1 py-0.5 text-[10px] font-medium bg-gradient-to-r from-purple-500 to-violet-500 text-white rounded">
|
||||
NEW
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
@@ -103,6 +127,20 @@ export const ChatHeader: Component<ChatHeaderProps> = (props) => {
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
{/* Show/hide older models toggle */}
|
||||
<Show when={hiddenModelCount() > 0}>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 mt-1 pt-1">
|
||||
<button
|
||||
onClick={() => setShowAllModels(!showAllModels())}
|
||||
class="w-full px-3 py-2 text-left text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<svg class={`w-3 h-3 transition-transform ${showAllModels() ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{showAllModels() ? 'Hide older models' : `Show ${hiddenModelCount()} older models`}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -112,11 +150,10 @@ export const ChatHeader: Component<ChatHeaderProps> = (props) => {
|
||||
<button
|
||||
onClick={props.onToggleAutonomous}
|
||||
disabled={props.isTogglingAutonomous}
|
||||
class={`p-2 rounded-lg transition-all ${
|
||||
props.autonomousMode
|
||||
class={`p-2 rounded-lg transition-all ${props.autonomousMode
|
||||
? 'text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/40 hover:bg-amber-200 dark:hover:bg-amber-900/60 shadow-sm'
|
||||
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
} ${props.isTogglingAutonomous ? 'opacity-50 cursor-wait' : ''}`}
|
||||
} ${props.isTogglingAutonomous ? 'opacity-50 cursor-wait' : ''}`}
|
||||
title={props.autonomousMode ? 'Autonomous Mode: ON (commands run without approval)' : 'Autonomous Mode: OFF (commands need approval)'}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -122,7 +122,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
{/* Thinking block - collapsed by default */}
|
||||
<Match when={evt.type === 'thinking' && evt.thinking}>
|
||||
<ThinkingBlock
|
||||
content={evt.thinking!}
|
||||
content={evt.thinking || ''}
|
||||
isStreaming={props.message.isStreaming}
|
||||
/>
|
||||
</Match>
|
||||
@@ -134,7 +134,12 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
|
||||
{/* Completed tool execution block */}
|
||||
<Match when={evt.type === 'tool' && evt.tool}>
|
||||
<ToolExecutionBlock tool={evt.tool!} />
|
||||
<ToolExecutionBlock tool={{
|
||||
name: evt.tool?.name || 'unknown',
|
||||
input: evt.tool?.input || '{}',
|
||||
output: evt.tool?.output || '',
|
||||
success: evt.tool?.success ?? true,
|
||||
}} />
|
||||
</Match>
|
||||
|
||||
{/* Content/text block */}
|
||||
@@ -150,7 +155,7 @@ export const MessageItem: Component<MessageItemProps> = (props) => {
|
||||
prose-headings:text-slate-900 dark:prose-headings:text-slate-100
|
||||
prose-strong:text-slate-900 dark:prose-strong:text-slate-100
|
||||
prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5"
|
||||
innerHTML={renderMarkdown(evt.content!)}
|
||||
innerHTML={renderMarkdown(evt.content || '')}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Component, For, Show, createSignal, createMemo } from 'solid-js';
|
||||
import { PROVIDER_DISPLAY_NAMES, getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils';
|
||||
import type { ModelInfo } from './types';
|
||||
|
||||
export interface ModelSelectorProps {
|
||||
models: ModelInfo[];
|
||||
selectedModel: string;
|
||||
defaultModelLabel?: string;
|
||||
chatOverrideModel?: string;
|
||||
chatOverrideLabel?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
onModelSelect: (modelId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable model selector dropdown with notable model filtering.
|
||||
* Shows only recent/notable models by default with a toggle to reveal older models.
|
||||
*/
|
||||
export const ModelSelector: Component<ModelSelectorProps> = (props) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
const [showAllModels, setShowAllModels] = createSignal(false);
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
|
||||
// Filter models by notable status (show only recent/notable models by default)
|
||||
const notableFilteredModels = createMemo(() => {
|
||||
if (showAllModels()) {
|
||||
return props.models;
|
||||
}
|
||||
const notable = props.models.filter(m => m.notable);
|
||||
return notable.length > 0 ? notable : props.models;
|
||||
});
|
||||
|
||||
// Count hidden (older) models
|
||||
const hiddenModelCount = createMemo(() => {
|
||||
const notable = props.models.filter(m => m.notable);
|
||||
return props.models.length - notable.length;
|
||||
});
|
||||
|
||||
// Apply search filter on top of notable filter
|
||||
const filteredModels = createMemo(() => {
|
||||
const query = searchQuery().trim().toLowerCase();
|
||||
const baseModels = notableFilteredModels();
|
||||
if (!query) return baseModels;
|
||||
return baseModels.filter((model) => {
|
||||
const provider = getProviderFromModelId(model.id);
|
||||
const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider;
|
||||
const modelName = model.name || '';
|
||||
return (
|
||||
model.id.toLowerCase().includes(query) ||
|
||||
modelName.toLowerCase().includes(query) ||
|
||||
(model.description || '').toLowerCase().includes(query) ||
|
||||
provider.toLowerCase().includes(query) ||
|
||||
providerName.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Check if typed query matches any model
|
||||
const customModelCandidate = createMemo(() => searchQuery().trim());
|
||||
const showCustomModelOption = createMemo(() => {
|
||||
const candidate = customModelCandidate();
|
||||
if (!candidate) return false;
|
||||
return !props.models.some((model) => model.id === candidate);
|
||||
});
|
||||
|
||||
const handleSelect = (modelId: string) => {
|
||||
props.onModelSelect(modelId);
|
||||
setIsOpen(false);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const candidate = customModelCandidate();
|
||||
if (candidate) {
|
||||
handleSelect(candidate);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedLabel = createMemo(() => {
|
||||
const selected = props.selectedModel?.trim();
|
||||
if (!selected) {
|
||||
const fallback = props.defaultModelLabel;
|
||||
return fallback ? `Default (${fallback})` : 'Default';
|
||||
}
|
||||
const match = props.models.find((model) => model.id === selected);
|
||||
if (match) return match.name || match.id.split(':').pop() || match.id;
|
||||
return selected;
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="relative" data-dropdown>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen())}
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-slate-600 dark:text-slate-300 hover:text-slate-800 dark:hover:text-slate-100 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 transition-colors"
|
||||
title="Select model for this chat"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="max-w-[120px] truncate font-medium">{selectedLabel()}</span>
|
||||
<Show when={props.isLoading}>
|
||||
<svg class="w-3 h-3 text-slate-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</Show>
|
||||
<svg class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div class="absolute right-0 top-full mt-1 w-80 max-h-96 overflow-hidden bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50">
|
||||
{/* Search bar */}
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-700">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search or enter model ID"
|
||||
class="flex-1 text-xs px-2 py-1.5 rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-purple-400/50"
|
||||
/>
|
||||
<Show when={props.onRefresh}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onRefresh?.()}
|
||||
disabled={props.isLoading}
|
||||
class="p-1.5 rounded-md text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
|
||||
title="Refresh models"
|
||||
>
|
||||
<svg class={`w-3.5 h-3.5 ${props.isLoading ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.32 9A7.5 7.5 0 0119 12.5M18.68 15A7.5 7.5 0 015 11.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
<Show when={props.error}>
|
||||
<div class="px-3 py-2 text-[11px] text-red-500 border-b border-slate-200 dark:border-slate-700">
|
||||
{props.error}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Model list */}
|
||||
<div class="max-h-72 overflow-y-auto py-1">
|
||||
{/* Default option */}
|
||||
<button
|
||||
onClick={() => handleSelect('')}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${!props.selectedModel ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">Default</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{props.defaultModelLabel ? `Use configured default model (${props.defaultModelLabel})` : 'Use configured default model'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Chat override option */}
|
||||
<Show when={props.chatOverrideModel}>
|
||||
<button
|
||||
onClick={() => handleSelect(props.chatOverrideModel!)}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${props.selectedModel === props.chatOverrideModel ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">Chat override</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{props.chatOverrideLabel || props.chatOverrideModel}
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* Custom model option */}
|
||||
<Show when={showCustomModelOption()}>
|
||||
<button
|
||||
onClick={() => handleSelect(customModelCandidate())}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700"
|
||||
>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">
|
||||
Use "{customModelCandidate()}"
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400">Custom model ID</div>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
{/* No results */}
|
||||
<Show when={!props.isLoading && filteredModels().length === 0}>
|
||||
<div class="px-3 py-4 text-center text-[11px] text-slate-500 dark:text-slate-400">
|
||||
No matching models.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Grouped models */}
|
||||
<For each={Array.from(groupModelsByProvider(filteredModels()).entries())}>
|
||||
{([provider, providerModels]) => (
|
||||
<>
|
||||
<div class="px-3 py-1.5 text-[11px] font-semibold text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-700/50 sticky top-0">
|
||||
{PROVIDER_DISPLAY_NAMES[provider] || provider}
|
||||
</div>
|
||||
<For each={providerModels}>
|
||||
{(model) => (
|
||||
<button
|
||||
onClick={() => handleSelect(model.id)}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${props.selectedModel === model.id ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-medium text-slate-900 dark:text-slate-100">
|
||||
{model.name || model.id.split(':').pop() || model.id}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={model.description}>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-2">
|
||||
{model.description}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={model.name && model.name !== model.id}>
|
||||
<div class="text-[10px] text-slate-400 dark:text-slate-500">
|
||||
{model.id}
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{/* Toggle to show older models */}
|
||||
<Show when={hiddenModelCount() > 0 && !searchQuery().trim()}>
|
||||
<div class="border-t border-slate-200 dark:border-slate-700 mt-1 pt-1">
|
||||
<button
|
||||
onClick={() => setShowAllModels(!showAllModels())}
|
||||
class="w-full px-3 py-2 text-left text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 flex items-center gap-1.5"
|
||||
>
|
||||
<svg class={`w-3 h-3 transition-transform ${showAllModels() ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{showAllModels() ? 'Hide older models' : `Show ${hiddenModelCount()} older models`}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -79,7 +79,7 @@ export const ToolExecutionBlock: Component<ToolExecutionBlockProps> = (props) =>
|
||||
|
||||
{/* Command/input - truncated */}
|
||||
<code class="text-slate-700 dark:text-slate-300 truncate flex-1">
|
||||
{props.tool.input.length > 60 ? props.tool.input.substring(0, 60) + '...' : props.tool.input}
|
||||
{(props.tool.input || '').length > 60 ? (props.tool.input || '').substring(0, 60) + '...' : (props.tool.input || '{}')}
|
||||
</code>
|
||||
|
||||
{/* Expand indicator if has more output */}
|
||||
|
||||
@@ -94,6 +94,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== assistantId) return msg;
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'content': {
|
||||
const content = event.data as string;
|
||||
@@ -119,7 +120,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
|
||||
case 'tool_start': {
|
||||
const data = event.data as { name: string; input: string };
|
||||
const data = (event.data || {}) as { name?: string; input?: string };
|
||||
|
||||
// Skip tool_start for "question" - these are handled by the question event type
|
||||
if (data.name === 'question' || data.name === 'Question') {
|
||||
@@ -127,7 +128,7 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
|
||||
const toolId = generateId(); // Unique ID to track this tool
|
||||
const pendingTool = { name: data.name, input: data.input };
|
||||
const pendingTool = { name: data.name || 'unknown', input: data.input || '{}' };
|
||||
|
||||
// Add to streamEvents in chronological position
|
||||
const updated = addStreamEvent(msg, {
|
||||
@@ -148,8 +149,8 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
const events = msg.streamEvents || [];
|
||||
|
||||
// Normalize tool name for matching - strip MCP server prefix (pulse_) which may be doubled
|
||||
const normalizeToolName = (name: string) => name.replace(/^(pulse_)+/, '');
|
||||
const normalizedEndName = normalizeToolName(data.name);
|
||||
const normalizeToolName = (name: string) => (name || '').replace(/^(pulse_)+/, '');
|
||||
const normalizedEndName = normalizeToolName(data.name || '');
|
||||
|
||||
// Find the matching pending tool (by normalized name)
|
||||
const matchingPendingIndex = pendingTools.findIndex(
|
||||
@@ -160,28 +161,54 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
: pendingTools;
|
||||
|
||||
const newToolCall: ToolExecution = {
|
||||
name: data.name,
|
||||
input: data.input,
|
||||
output: data.output,
|
||||
success: data.success,
|
||||
name: data.name || 'unknown',
|
||||
input: data.input || '{}',
|
||||
output: data.output || '',
|
||||
success: data.success ?? true,
|
||||
};
|
||||
|
||||
// Find the pending_tool event in streamEvents and replace it with completed tool
|
||||
// Search from the end to find the most recent matching pending tool
|
||||
let updatedEvents = [...events];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const evt = events[i];
|
||||
if (evt.type === 'pending_tool' && normalizeToolName(evt.pendingTool?.name || '') === normalizedEndName) {
|
||||
// Replace pending with completed
|
||||
updatedEvents[i] = { type: 'tool', tool: newToolCall };
|
||||
break;
|
||||
// Check if there's an approval card for this tool
|
||||
// If so, we need to remove both the pending_tool AND the approval,
|
||||
// then add the completed tool at the end (since execution happened AFTER approval)
|
||||
const hasApproval = events.some(
|
||||
(evt) => evt.type === 'approval' && normalizeToolName(evt.approval?.toolName || '') === normalizedEndName
|
||||
);
|
||||
|
||||
let updatedEvents: typeof events;
|
||||
if (hasApproval) {
|
||||
// Remove pending_tool and approval, add completed tool at end
|
||||
updatedEvents = events.filter((evt) => {
|
||||
if (evt.type === 'pending_tool' && normalizeToolName(evt.pendingTool?.name || '') === normalizedEndName) {
|
||||
return false;
|
||||
}
|
||||
if (evt.type === 'approval' && normalizeToolName(evt.approval?.toolName || '') === normalizedEndName) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
updatedEvents.push({ type: 'tool', tool: newToolCall });
|
||||
} else {
|
||||
// No approval - just replace pending_tool in place
|
||||
updatedEvents = [...events];
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const evt = events[i];
|
||||
if (evt.type === 'pending_tool' && normalizeToolName(evt.pendingTool?.name || '') === normalizedEndName) {
|
||||
updatedEvents[i] = { type: 'tool', tool: newToolCall };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove from pendingApprovals if present
|
||||
const updatedApprovals = (msg.pendingApprovals || []).filter(
|
||||
(a) => normalizeToolName(a.toolName || '') !== normalizedEndName
|
||||
);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
streamEvents: updatedEvents,
|
||||
pendingTools: updatedPending,
|
||||
pendingApprovals: updatedApprovals,
|
||||
toolCalls: [...(msg.toolCalls || []), newToolCall],
|
||||
};
|
||||
}
|
||||
@@ -260,6 +287,10 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
default:
|
||||
return msg;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[useChat] Error processing event', { event, error: err });
|
||||
return msg; // Return unchanged message on error
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -465,25 +496,54 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
try {
|
||||
// Send answer to OpenCode via API
|
||||
await OpenCodeAPI.answerQuestion(questionId, answers);
|
||||
// Remove the question card after successful answer
|
||||
|
||||
// Remove the question card - it's been handled
|
||||
updateQuestion(messageId, questionId, { removed: true });
|
||||
|
||||
// After answering, OpenCode continues processing but the SSE stream has closed.
|
||||
// We need to send a follow-up message to get the continuation.
|
||||
const answerSummary = answers.map(a => a.value).join(', ');
|
||||
logger.debug('[useChat] Question answered, sending continuation', { questionId, answerSummary });
|
||||
// After answering, check if the stream is still active.
|
||||
// If it closed (e.g. on question), we force a re-connection to receive continuation events.
|
||||
if (!isLoading()) {
|
||||
logger.debug('[useChat] Stream closed, re-initiating to catch continuation', {
|
||||
questionId,
|
||||
messageId,
|
||||
});
|
||||
|
||||
// Wait for any previous stream to finish
|
||||
if (isLoading()) {
|
||||
logger.debug('[useChat] Waiting for stream to finish before sending answer');
|
||||
const idle = await waitForIdleInternal(10000);
|
||||
if (!idle) {
|
||||
logger.warn('[useChat] Timeout waiting for stream, sending anyway');
|
||||
const currentSessionId = sessionId();
|
||||
if (currentSessionId) {
|
||||
setIsLoading(true);
|
||||
abortControllerRef = new AbortController();
|
||||
|
||||
// Set the message back to streaming state to show the AI is working
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === messageId ? { ...m, isStreaming: true } : m))
|
||||
);
|
||||
|
||||
OpenCodeAPI.chat(
|
||||
'', // Empty prompt - just resume listening for completion
|
||||
currentSessionId,
|
||||
model() || undefined,
|
||||
(event) => {
|
||||
processEvent(messageId, event);
|
||||
},
|
||||
abortControllerRef.signal
|
||||
)
|
||||
.catch((err) => {
|
||||
if (err instanceof Error && err.name === 'AbortError') return;
|
||||
logger.error('[useChat] Re-connection failed:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
abortControllerRef = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send the answer as a message to continue the conversation
|
||||
await sendMessage(answerSummary || 'Continue');
|
||||
logger.debug('[useChat] Question answered, waiting for AI to continue', {
|
||||
questionId,
|
||||
});
|
||||
|
||||
// Brief delay to allow backend processing to settle
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
} catch (error) {
|
||||
logger.error('[useChat] Failed to answer question:', error);
|
||||
notificationStore.error('Failed to answer question');
|
||||
@@ -491,25 +551,6 @@ export function useChat(options: UseChatOptions = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
// Internal helper to wait for idle state
|
||||
const waitForIdleInternal = (timeoutMs: number): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!isLoading()) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
const startTime = Date.now();
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!isLoading()) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(true);
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(false);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
// Wait for the chat to become idle (not loading)
|
||||
// Useful for sending follow-up messages after approvals
|
||||
|
||||
@@ -6,7 +6,7 @@ import { aiChatStore } from '@/stores/aiChat';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { useChat } from './hooks/useChat';
|
||||
import { ChatMessages } from './ChatMessages';
|
||||
import { PROVIDER_DISPLAY_NAMES, getProviderFromModelId, groupModelsByProvider } from '../aiChatUtils';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import type { PendingApproval, PendingQuestion, ModelInfo } from './types';
|
||||
|
||||
const MODEL_LEGACY_STORAGE_KEY = 'pulse:ai_chat_model';
|
||||
@@ -29,11 +29,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const [input, setInput] = createSignal('');
|
||||
const [sessions, setSessions] = createSignal<ChatSession[]>([]);
|
||||
const [showSessions, setShowSessions] = createSignal(false);
|
||||
const [showModelSelector, setShowModelSelector] = createSignal(false);
|
||||
const [models, setModels] = createSignal<ModelInfo[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = createSignal(false);
|
||||
const [modelsError, setModelsError] = createSignal('');
|
||||
const [modelQuery, setModelQuery] = createSignal('');
|
||||
const [defaultModel, setDefaultModel] = createSignal('');
|
||||
const [chatOverrideModel, setChatOverrideModel] = createSignal('');
|
||||
const [showSessionActions, setShowSessionActions] = createSignal(false);
|
||||
@@ -105,40 +103,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
return match ? (match.name || match.id.split(':').pop() || match.id) : override;
|
||||
});
|
||||
|
||||
const selectedModelLabel = createMemo(() => {
|
||||
const selected = chat.model().trim();
|
||||
if (!selected) {
|
||||
const fallback = defaultModelLabel();
|
||||
return fallback ? `Default (${fallback})` : 'Default';
|
||||
}
|
||||
const match = models().find((model) => model.id === selected);
|
||||
if (match) return match.name || match.id.split(':').pop() || match.id;
|
||||
return selected;
|
||||
});
|
||||
|
||||
const filteredModels = createMemo(() => {
|
||||
const query = modelQuery().trim().toLowerCase();
|
||||
if (!query) return models();
|
||||
return models().filter((model) => {
|
||||
const provider = getProviderFromModelId(model.id);
|
||||
const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider;
|
||||
const modelName = model.name || '';
|
||||
return (
|
||||
model.id.toLowerCase().includes(query) ||
|
||||
modelName.toLowerCase().includes(query) ||
|
||||
(model.description || '').toLowerCase().includes(query) ||
|
||||
provider.toLowerCase().includes(query) ||
|
||||
providerName.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const customModelCandidate = createMemo(() => modelQuery().trim());
|
||||
const showCustomModelOption = createMemo(() => {
|
||||
const candidate = customModelCandidate();
|
||||
if (!candidate) return false;
|
||||
return !models().some((model) => model.id === candidate);
|
||||
});
|
||||
|
||||
const loadModels = async (notify = false) => {
|
||||
if (notify) {
|
||||
@@ -184,8 +148,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const selectModel = (modelId: string) => {
|
||||
chat.setModel(modelId);
|
||||
updateStoredModel(chat.sessionId(), modelId);
|
||||
setShowModelSelector(false);
|
||||
setModelQuery('');
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
@@ -197,8 +159,11 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (chat.model()) {
|
||||
chat.setModel('');
|
||||
// If there's no stored model for this session but we have a current selection,
|
||||
// preserve it (and migrate it to this session)
|
||||
const currentModel = chat.model();
|
||||
if (currentModel && sessionId) {
|
||||
updateStoredModel(sessionId, currentModel);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -249,7 +214,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// Only close if click is outside dropdown containers
|
||||
if (!target.closest('[data-dropdown]')) {
|
||||
setShowModelSelector(false);
|
||||
setShowSessions(false);
|
||||
setShowSessionActions(false);
|
||||
}
|
||||
@@ -313,63 +277,26 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
chat.updateApproval(messageId, approval.toolId, { isExecuting: true });
|
||||
|
||||
try {
|
||||
const result = await OpenCodeAPI.approveCommand(approval.approvalId);
|
||||
// Call the approve endpoint - this marks it as approved in the backend
|
||||
// The agentic loop will detect this and execute the command
|
||||
// Execution results will come via tool_end event in the stream
|
||||
await OpenCodeAPI.approveCommand(approval.approvalId);
|
||||
|
||||
// Remove from pending approvals
|
||||
// Remove from pending approvals - the tool_end event will show the result
|
||||
chat.updateApproval(messageId, approval.toolId, { removed: true });
|
||||
|
||||
// Add tool result if command was executed
|
||||
if (result.approved) {
|
||||
const typedResult = result as {
|
||||
result?: { stdout?: string; stderr?: string; exit_code?: number };
|
||||
error?: string;
|
||||
message?: string;
|
||||
executed?: boolean;
|
||||
};
|
||||
const execResult = typedResult.result;
|
||||
logger.debug('[AIChat] Command approved, waiting for agentic loop to execute', {
|
||||
approvalId: approval.approvalId,
|
||||
toolName: approval.toolName,
|
||||
});
|
||||
|
||||
let output: string;
|
||||
let success: boolean;
|
||||
// Note: We don't manually add tool results or send continuation messages here.
|
||||
// The agentic loop will:
|
||||
// 1. Detect the approval
|
||||
// 2. Re-execute the tool with the approval_id
|
||||
// 3. Send a tool_end event with the result
|
||||
// 4. Continue the conversation automatically
|
||||
|
||||
if (execResult) {
|
||||
output = `Exit code: ${execResult.exit_code}\n${execResult.stdout || ''}${execResult.stderr ? '\nStderr: ' + execResult.stderr : ''}`;
|
||||
success = execResult.exit_code === 0;
|
||||
} else if (typedResult.error) {
|
||||
output = `Execution failed: ${typedResult.error}`;
|
||||
success = false;
|
||||
} else if (typedResult.message) {
|
||||
output = typedResult.message;
|
||||
success = false;
|
||||
} else {
|
||||
output = 'Command approved but no execution result available.';
|
||||
success = false;
|
||||
}
|
||||
|
||||
chat.addToolResult(messageId, {
|
||||
name: approval.toolName,
|
||||
input: approval.command,
|
||||
output: output.trim(),
|
||||
success,
|
||||
});
|
||||
|
||||
// Continue the conversation - short message, output is already in the tool result
|
||||
const continuationMessage = success
|
||||
? 'Command executed. Please analyze the result and continue.'
|
||||
: 'Command completed with issues. Please analyze and advise.';
|
||||
|
||||
// Wait for any ongoing stream to complete before sending continuation
|
||||
if (chat.isLoading()) {
|
||||
logger.debug('[AIChat] Waiting for chat to become idle before continuation');
|
||||
const isIdle = await chat.waitForIdle(30000);
|
||||
if (!isIdle) {
|
||||
logger.warn('[AIChat] Timeout waiting for chat to become idle');
|
||||
notificationStore.warning('Chat is busy. Sending continuation anyway...');
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('[AIChat] Sending continuation message', { sessionId: chat.sessionId() });
|
||||
await chat.sendMessage(continuationMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AIChat] Approval failed:', error);
|
||||
notificationStore.error('Failed to approve command');
|
||||
@@ -408,26 +335,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
chat.updateQuestion(messageId, questionId, { removed: true });
|
||||
};
|
||||
|
||||
const toggleModelSelector = () => {
|
||||
const next = !showModelSelector();
|
||||
setShowModelSelector(next);
|
||||
if (next) {
|
||||
setShowSessions(false);
|
||||
setModelQuery('');
|
||||
if (models().length === 0 && !modelsLoading()) {
|
||||
loadModels();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelInputKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const candidate = customModelCandidate();
|
||||
if (candidate) {
|
||||
selectModel(candidate);
|
||||
}
|
||||
};
|
||||
|
||||
// Session action handlers
|
||||
const handleSummarize = async () => {
|
||||
@@ -508,139 +415,22 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
{/* Model selector */}
|
||||
<div class="relative" data-dropdown>
|
||||
<button
|
||||
onClick={toggleModelSelector}
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] text-slate-600 dark:text-slate-300 hover:text-slate-800 dark:hover:text-slate-100 rounded-lg border border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 transition-colors"
|
||||
title="Select model for this chat"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="max-w-[120px] truncate font-medium">{selectedModelLabel()}</span>
|
||||
<Show when={modelsLoading()}>
|
||||
<svg class="w-3 h-3 text-slate-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</Show>
|
||||
<svg class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<Show when={showModelSelector()}>
|
||||
<div class="absolute right-0 top-full mt-1 w-80 max-h-96 overflow-hidden bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 z-50">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-700">
|
||||
<input
|
||||
type="text"
|
||||
value={modelQuery()}
|
||||
onInput={(e) => setModelQuery(e.currentTarget.value)}
|
||||
onKeyDown={handleModelInputKeyDown}
|
||||
placeholder="Search or enter model ID"
|
||||
class="flex-1 text-xs px-2 py-1.5 rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-purple-400/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadModels(true)}
|
||||
disabled={modelsLoading()}
|
||||
class="p-1.5 rounded-md text-slate-500 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50"
|
||||
title="Refresh models"
|
||||
>
|
||||
<svg class={`w-3.5 h-3.5 ${modelsLoading() ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v6h6M20 20v-6h-6M5.32 9A7.5 7.5 0 0119 12.5M18.68 15A7.5 7.5 0 015 11.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={modelsError()}>
|
||||
<div class="px-3 py-2 text-[11px] text-red-500 border-b border-slate-200 dark:border-slate-700">
|
||||
{modelsError()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="max-h-72 overflow-y-auto py-1">
|
||||
<button
|
||||
onClick={() => selectModel('')}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${!chat.model() ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">Default</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{defaultModelLabel() ? `Use configured default model (${defaultModelLabel()})` : 'Use configured default model'}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Show when={chatOverrideModel()}>
|
||||
<button
|
||||
onClick={() => selectModel(chatOverrideModel())}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${chat.model() === chatOverrideModel() ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">Chat override</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400">
|
||||
{chatOverrideLabel() || chatOverrideModel()}
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={showCustomModelOption()}>
|
||||
<button
|
||||
onClick={() => selectModel(customModelCandidate())}
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700"
|
||||
>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">
|
||||
Use "{customModelCandidate()}"
|
||||
</div>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400">Custom model ID</div>
|
||||
</button>
|
||||
</Show>
|
||||
|
||||
<Show when={!modelsLoading() && filteredModels().length === 0}>
|
||||
<div class="px-3 py-4 text-center text-[11px] text-slate-500 dark:text-slate-400">
|
||||
No matching models.
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={Array.from(groupModelsByProvider(filteredModels()).entries())}>
|
||||
{([provider, providerModels]) => (
|
||||
<>
|
||||
<div class="px-3 py-1.5 text-[11px] font-semibold text-slate-500 dark:text-slate-400 bg-slate-50 dark:bg-slate-700/50 sticky top-0">
|
||||
{PROVIDER_DISPLAY_NAMES[provider] || provider}
|
||||
</div>
|
||||
<For each={providerModels}>
|
||||
{(model) => (
|
||||
<button
|
||||
onClick={() => selectModel(model.id)}
|
||||
class={`w-full px-3 py-2 text-left text-sm hover:bg-slate-50 dark:hover:bg-slate-700 ${chat.model() === model.id ? 'bg-purple-50 dark:bg-purple-900/30' : ''}`}
|
||||
>
|
||||
<div class="font-medium text-slate-900 dark:text-slate-100">
|
||||
{model.name || model.id.split(':').pop() || model.id}
|
||||
</div>
|
||||
<Show when={model.description}>
|
||||
<div class="text-[11px] text-slate-500 dark:text-slate-400 line-clamp-2">
|
||||
{model.description}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={model.name && model.name !== model.id}>
|
||||
<div class="text-[10px] text-slate-400 dark:text-slate-500">
|
||||
{model.id}
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<ModelSelector
|
||||
models={models()}
|
||||
selectedModel={chat.model()}
|
||||
defaultModelLabel={defaultModelLabel()}
|
||||
chatOverrideModel={chatOverrideModel()}
|
||||
chatOverrideLabel={chatOverrideLabel()}
|
||||
isLoading={modelsLoading()}
|
||||
error={modelsError()}
|
||||
onModelSelect={selectModel}
|
||||
onRefresh={() => loadModels(true)}
|
||||
/>
|
||||
|
||||
{/* Session picker */}
|
||||
<div class="relative" data-dropdown>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModelSelector(false);
|
||||
setShowSessions(!showSessions());
|
||||
}}
|
||||
class="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
@@ -709,7 +499,6 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
||||
notificationStore.info('Send a message first to start a session');
|
||||
return;
|
||||
}
|
||||
setShowModelSelector(false);
|
||||
setShowSessions(false);
|
||||
setShowSessionActions(!showSessionActions());
|
||||
}}
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
notable?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatContextItem {
|
||||
|
||||
@@ -7,6 +7,7 @@ export const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
deepseek: 'DeepSeek',
|
||||
gemini: 'Google Gemini',
|
||||
ollama: 'Ollama',
|
||||
};
|
||||
|
||||
@@ -20,12 +21,15 @@ export function getProviderFromModelId(modelId: string): string {
|
||||
if (modelId.includes('claude') || modelId.includes('opus') || modelId.includes('sonnet') || modelId.includes('haiku')) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (modelId.includes('gpt') || modelId.includes('o1') || modelId.includes('o3')) {
|
||||
if (modelId.includes('gpt') || modelId.includes('o1') || modelId.includes('o3') || modelId.includes('o4')) {
|
||||
return 'openai';
|
||||
}
|
||||
if (modelId.includes('deepseek')) {
|
||||
return 'deepseek';
|
||||
}
|
||||
if (modelId.includes('gemini')) {
|
||||
return 'gemini';
|
||||
}
|
||||
return 'ollama';
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface ModelInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
is_default?: boolean;
|
||||
notable?: boolean;
|
||||
}
|
||||
|
||||
export interface AISettings {
|
||||
|
||||
Reference in New Issue
Block a user