quick question feature
This commit is contained in:
482
web/src/App.tsx
482
web/src/App.tsx
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
|
||||
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -92,9 +92,15 @@ const EMPTY_MODEL_CATALOG: ModelCatalogResponse["providers"] = {
|
||||
};
|
||||
|
||||
const MODEL_PREFERENCES_STORAGE_KEY = "sybil:modelPreferencesByProvider";
|
||||
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
|
||||
|
||||
type ProviderModelPreferences = Record<Provider, string | null>;
|
||||
|
||||
type QuickQuestionModelSelection = {
|
||||
provider: Provider;
|
||||
modelPreferences: ProviderModelPreferences;
|
||||
};
|
||||
|
||||
const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
|
||||
openai: null,
|
||||
anthropic: null,
|
||||
@@ -292,6 +298,37 @@ function loadStoredModelPreferences() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeStoredProvider(value: unknown): Provider {
|
||||
return value === "anthropic" || value === "xai" || value === "openai" ? value : "openai";
|
||||
}
|
||||
|
||||
function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES;
|
||||
const parsed = value as Partial<Record<Provider, unknown>>;
|
||||
return {
|
||||
openai: typeof parsed.openai === "string" && parsed.openai.trim() ? parsed.openai.trim() : null,
|
||||
anthropic: typeof parsed.anthropic === "string" && parsed.anthropic.trim() ? parsed.anthropic.trim() : null,
|
||||
xai: typeof parsed.xai === "string" && parsed.xai.trim() ? parsed.xai.trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection {
|
||||
if (typeof window === "undefined") {
|
||||
return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY);
|
||||
if (!raw) return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
|
||||
const parsed = JSON.parse(raw) as { provider?: unknown; modelPreferences?: unknown };
|
||||
return {
|
||||
provider: normalizeStoredProvider(parsed.provider),
|
||||
modelPreferences: normalizeStoredModelPreferences(parsed.modelPreferences),
|
||||
};
|
||||
} catch {
|
||||
return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES };
|
||||
}
|
||||
}
|
||||
|
||||
function pickProviderModel(options: string[], preferred: string | null) {
|
||||
if (preferred?.trim()) return preferred.trim();
|
||||
return options[0] ?? "";
|
||||
@@ -620,6 +657,22 @@ export default function App() {
|
||||
const stored = loadStoredModelPreferences();
|
||||
return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0];
|
||||
});
|
||||
const [quickProvider, setQuickProvider] = useState<Provider>(() => loadStoredQuickQuestionModelSelection().provider);
|
||||
const [quickProviderModelPreferences, setQuickProviderModelPreferences] = useState<ProviderModelPreferences>(
|
||||
() => loadStoredQuickQuestionModelSelection().modelPreferences
|
||||
);
|
||||
const [quickModel, setQuickModel] = useState(() => {
|
||||
const stored = loadStoredQuickQuestionModelSelection();
|
||||
return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0];
|
||||
});
|
||||
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
|
||||
const [quickPrompt, setQuickPrompt] = useState("");
|
||||
const [quickSubmittedPrompt, setQuickSubmittedPrompt] = useState<string | null>(null);
|
||||
const [quickSubmittedModelSelection, setQuickSubmittedModelSelection] = useState<{ provider: Provider; model: string } | null>(null);
|
||||
const [quickQuestionMessages, setQuickQuestionMessages] = useState<Message[]>([]);
|
||||
const [isQuickQuestionSending, setIsQuickQuestionSending] = useState(false);
|
||||
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
|
||||
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
|
||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -631,6 +684,7 @@ export default function App() {
|
||||
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
||||
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
||||
const searchRunAbortRef = useRef<AbortController | null>(null);
|
||||
const quickQuestionAbortRef = useRef<AbortController | null>(null);
|
||||
const searchRunCounterRef = useRef(0);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
const wasSendingRef = useRef(false);
|
||||
@@ -713,6 +767,12 @@ export default function App() {
|
||||
setPendingChatState(null);
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setIsQuickQuestionOpen(false);
|
||||
setQuickPrompt("");
|
||||
setQuickSubmittedPrompt(null);
|
||||
setQuickSubmittedModelSelection(null);
|
||||
setQuickQuestionMessages([]);
|
||||
setQuickQuestionError(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
@@ -846,6 +906,7 @@ export default function App() {
|
||||
}, [isAuthenticated, selectedItem]);
|
||||
|
||||
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
|
||||
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (model.trim()) return;
|
||||
@@ -859,6 +920,46 @@ export default function App() {
|
||||
window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences));
|
||||
}, [providerModelPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (quickModel.trim()) return;
|
||||
setQuickModel((current) => {
|
||||
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
|
||||
});
|
||||
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(
|
||||
QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
provider: quickProvider,
|
||||
modelPreferences: quickProviderModelPreferences,
|
||||
} satisfies QuickQuestionModelSelection)
|
||||
);
|
||||
}, [quickProvider, quickProviderModelPreferences]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQuickQuestionOpen || typeof window === "undefined") return;
|
||||
window.requestAnimationFrame(() => {
|
||||
const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null;
|
||||
if (!textarea) return;
|
||||
textarea.focus();
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
if (textarea.value.length > 0) {
|
||||
textarea.select();
|
||||
}
|
||||
});
|
||||
}, [isQuickQuestionOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null;
|
||||
if (!textarea) return;
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}, [quickPrompt, isQuickQuestionOpen]);
|
||||
|
||||
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
|
||||
const isChatReplyStreamingInView =
|
||||
isSending &&
|
||||
@@ -933,6 +1034,8 @@ export default function App() {
|
||||
return () => {
|
||||
searchRunAbortRef.current?.abort();
|
||||
searchRunAbortRef.current = null;
|
||||
quickQuestionAbortRef.current?.abort();
|
||||
quickQuestionAbortRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -960,6 +1063,18 @@ export default function App() {
|
||||
}
|
||||
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
|
||||
}, [isSearchMode, messages, pendingChatState, selectedItem]);
|
||||
const quickAnswerText = useMemo(() => {
|
||||
for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = quickQuestionMessages[index];
|
||||
if (message.role === "assistant") return message.content;
|
||||
}
|
||||
return "";
|
||||
}, [quickQuestionMessages]);
|
||||
const canConvertQuickQuestion =
|
||||
Boolean(quickSubmittedPrompt?.trim()) &&
|
||||
Boolean(quickSubmittedModelSelection?.model.trim()) &&
|
||||
Boolean(quickAnswerText.trim()) &&
|
||||
!isQuickQuestionSending;
|
||||
|
||||
const selectedChatSummary = useMemo(() => {
|
||||
if (!selectedItem || selectedItem.kind !== "chat") return null;
|
||||
@@ -1028,6 +1143,12 @@ export default function App() {
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenQuickQuestion = () => {
|
||||
setQuickQuestionError(null);
|
||||
setIsQuickQuestionOpen(true);
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateSearch = () => {
|
||||
setError(null);
|
||||
setContextMenu(null);
|
||||
@@ -1068,6 +1189,15 @@ export default function App() {
|
||||
if (!hasPrimaryModifier || event.altKey) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "i" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
setQuickQuestionError(null);
|
||||
setIsQuickQuestionOpen((current) => !current);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isQuickQuestionOpen) return;
|
||||
|
||||
if (key === "j") {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
@@ -1087,7 +1217,7 @@ export default function App() {
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [filteredSidebarItems, isAuthenticated]);
|
||||
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
|
||||
|
||||
const openContextMenu = (event: MouseEvent, item: SidebarSelection) => {
|
||||
event.preventDefault();
|
||||
@@ -1138,6 +1268,17 @@ export default function App() {
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isQuickQuestionOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
setIsQuickQuestionOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isQuickQuestionOpen]);
|
||||
|
||||
const handleOpenAttachmentPicker = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
@@ -1587,6 +1728,182 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendQuickQuestion = async () => {
|
||||
const content = quickPrompt.trim();
|
||||
if (!content || isQuickQuestionSending || isConvertingQuickQuestion) return;
|
||||
|
||||
const selectedModel = quickModel.trim();
|
||||
if (!selectedModel) {
|
||||
setQuickQuestionError("No model available for selected provider");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const optimisticAssistantMessage: Message = {
|
||||
id: `temp-assistant-quick-${Date.now()}`,
|
||||
createdAt: now,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
name: null,
|
||||
metadata: null,
|
||||
};
|
||||
|
||||
quickQuestionAbortRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
quickQuestionAbortRef.current = abortController;
|
||||
|
||||
setQuickQuestionError(null);
|
||||
setQuickSubmittedPrompt(content);
|
||||
setQuickSubmittedModelSelection({ provider: quickProvider, model: selectedModel });
|
||||
setQuickQuestionMessages([optimisticAssistantMessage]);
|
||||
setIsQuickQuestionSending(true);
|
||||
|
||||
let streamErrorMessage: string | null = null;
|
||||
|
||||
try {
|
||||
await runCompletionStream(
|
||||
{
|
||||
persist: false,
|
||||
provider: quickProvider,
|
||||
model: selectedModel,
|
||||
messages: [{ role: "user", content }],
|
||||
},
|
||||
{
|
||||
onToolCall: (payload) => {
|
||||
setQuickQuestionMessages((current) => {
|
||||
if (
|
||||
current.some(
|
||||
(message) =>
|
||||
asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}`
|
||||
)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const toolMessage = buildOptimisticToolMessage(payload);
|
||||
const assistantIndex = current.findIndex(
|
||||
(message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-quick-")
|
||||
);
|
||||
if (assistantIndex < 0) return current.concat(toolMessage);
|
||||
return [
|
||||
...current.slice(0, assistantIndex),
|
||||
toolMessage,
|
||||
...current.slice(assistantIndex),
|
||||
];
|
||||
});
|
||||
},
|
||||
onDelta: (payload) => {
|
||||
if (!payload.text) return;
|
||||
setQuickQuestionMessages((current) => {
|
||||
let updated = false;
|
||||
const nextMessages = current.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: message.content + payload.text };
|
||||
});
|
||||
return updated ? nextMessages : current;
|
||||
});
|
||||
},
|
||||
onDone: (payload) => {
|
||||
setQuickQuestionMessages((current) => {
|
||||
let updated = false;
|
||||
const nextMessages = current.map((message, index, all) => {
|
||||
const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-");
|
||||
if (!isTarget) return message;
|
||||
updated = true;
|
||||
return { ...message, content: payload.text };
|
||||
});
|
||||
return updated ? nextMessages : current;
|
||||
});
|
||||
},
|
||||
onError: (payload) => {
|
||||
streamErrorMessage = payload.message;
|
||||
},
|
||||
},
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
|
||||
if (streamErrorMessage) {
|
||||
throw new Error(streamErrorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setQuickQuestionError(message);
|
||||
}
|
||||
} finally {
|
||||
if (quickQuestionAbortRef.current === abortController) {
|
||||
quickQuestionAbortRef.current = null;
|
||||
}
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsQuickQuestionSending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvertQuickQuestionToChat = async () => {
|
||||
const question = quickSubmittedPrompt?.trim();
|
||||
const answer = quickAnswerText.trim();
|
||||
const selection = quickSubmittedModelSelection;
|
||||
if (!question || !answer || !selection || isQuickQuestionSending || isConvertingQuickQuestion) return;
|
||||
|
||||
setQuickQuestionError(null);
|
||||
setIsConvertingQuickQuestion(true);
|
||||
|
||||
try {
|
||||
const title = question.split(/\r?\n/)[0]?.trim().slice(0, 48) || "Quick question";
|
||||
const chat = await createChat({
|
||||
title,
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
messages: [
|
||||
{ role: "user", content: question },
|
||||
{ role: "assistant", content: answer },
|
||||
],
|
||||
});
|
||||
|
||||
setDraftKind(null);
|
||||
setPendingChatState(null);
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setIsQuickQuestionOpen(false);
|
||||
setProvider(selection.provider);
|
||||
setModel(selection.model);
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
});
|
||||
setSelectedItem({ kind: "chat", id: chat.id });
|
||||
setSelectedChat({
|
||||
id: chat.id,
|
||||
title: chat.title,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: chat.updatedAt,
|
||||
initiatedProvider: chat.initiatedProvider,
|
||||
initiatedModel: chat.initiatedModel,
|
||||
lastUsedProvider: chat.lastUsedProvider,
|
||||
lastUsedModel: chat.lastUsedModel,
|
||||
messages: [],
|
||||
});
|
||||
setSelectedSearch(null);
|
||||
await refreshCollections({ kind: "chat", id: chat.id });
|
||||
await refreshChat(chat.id);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("bearer token")) {
|
||||
handleAuthFailure(message);
|
||||
} else {
|
||||
setQuickQuestionError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsConvertingQuickQuestion(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const content = composer.trim();
|
||||
const attachments = pendingAttachments;
|
||||
@@ -1683,13 +2000,30 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-3 pb-3">
|
||||
<Button className="h-11 w-full justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New chat
|
||||
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
|
||||
{primaryShortcutModifier} J
|
||||
</span>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button className="h-11 min-w-0 flex-1 justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New chat
|
||||
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
|
||||
{primaryShortcutModifier} J
|
||||
</span>
|
||||
</Button>
|
||||
<div className="group relative">
|
||||
<Button
|
||||
className="h-11 w-11 rounded-lg"
|
||||
onClick={handleOpenQuickQuestion}
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
title={`${primaryShortcutModifier}+i`}
|
||||
aria-label="Quick question"
|
||||
>
|
||||
<Rabbit className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="pointer-events-none absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-md border border-violet-300/22 bg-[hsl(238_48%_7%)] px-2 py-1 text-xs font-semibold text-violet-100/90 opacity-0 shadow-xl shadow-black/35 transition group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{primaryShortcutModifier}+i
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
|
||||
<Search className="h-4 w-4" />
|
||||
New search
|
||||
@@ -1961,6 +2295,136 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{isQuickQuestionOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="quick-question-title"
|
||||
className="glass-panel flex max-h-[88vh] w-full max-w-3xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 id="quick-question-title" className="text-sm font-semibold text-violet-50">
|
||||
Quick question
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setIsQuickQuestionOpen(false)}
|
||||
aria-label="Close quick question"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3">
|
||||
<Textarea
|
||||
id="quick-question-input"
|
||||
rows={2}
|
||||
value={quickPrompt}
|
||||
onInput={(event) => {
|
||||
const textarea = event.currentTarget;
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
const nextPrompt = textarea.value;
|
||||
if (nextPrompt !== quickPrompt) {
|
||||
quickQuestionAbortRef.current?.abort();
|
||||
quickQuestionAbortRef.current = null;
|
||||
setIsQuickQuestionSending(false);
|
||||
setQuickSubmittedPrompt(null);
|
||||
setQuickSubmittedModelSelection(null);
|
||||
setQuickQuestionMessages([]);
|
||||
setQuickQuestionError(null);
|
||||
}
|
||||
setQuickPrompt(nextPrompt);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSendQuickQuestion();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask Sybil..."
|
||||
className="max-h-36 min-h-[4.75rem] resize-none overflow-y-auto border-violet-300/24 bg-background/72 text-base text-violet-50 placeholder:text-violet-200/45"
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||
/>
|
||||
|
||||
<div className="h-[min(34vh,22rem)] overflow-y-auto rounded-xl border border-violet-300/16 bg-background/38 px-3 py-4">
|
||||
{quickQuestionMessages.length ? (
|
||||
<ChatMessagesPanel messages={quickQuestionMessages} isLoading={false} isSending={isQuickQuestionSending} />
|
||||
) : null}
|
||||
{quickQuestionError ? (
|
||||
<p className="text-sm text-rose-300">{quickQuestionError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<select
|
||||
className="h-10 min-w-32 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
value={quickProvider}
|
||||
onChange={(event) => {
|
||||
const nextProvider = event.currentTarget.value as Provider;
|
||||
setQuickProvider(nextProvider);
|
||||
const options = getModelOptions(modelCatalog, nextProvider);
|
||||
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider]));
|
||||
}}
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||
aria-label="Quick question provider"
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="xai">xAI</option>
|
||||
</select>
|
||||
<ModelCombobox
|
||||
options={quickProviderModelOptions}
|
||||
value={quickModel}
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
|
||||
onChange={(nextModel) => {
|
||||
const normalizedModel = nextModel.trim();
|
||||
setQuickModel(normalizedModel);
|
||||
setQuickProviderModelPreferences((current) => ({
|
||||
...current,
|
||||
[quickProvider]: normalizedModel || null,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
onClick={() => void handleConvertQuickQuestionToChat()}
|
||||
disabled={!canConvertQuickQuestion || isConvertingQuickQuestion}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Convert to chat
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 w-10 rounded-lg"
|
||||
onClick={() => void handleSendQuickQuestion()}
|
||||
size="icon"
|
||||
disabled={isQuickQuestionSending || isConvertingQuickQuestion || !quickPrompt.trim()}
|
||||
aria-label="Ask quick question"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user