diff --git a/app/(main)/chat/page.tsx b/app/(main)/chat/page.tsx index 2dd5685..3d1e651 100644 --- a/app/(main)/chat/page.tsx +++ b/app/(main)/chat/page.tsx @@ -23,11 +23,13 @@ import { useVoiceChat } from "@/app/hooks/useVoiceChat"; import { configToBlob, createLLMCall, + extractAssistantAudio, extractAssistantText, pollLLMCall, } from "@/app/lib/chatClient"; import { useChatStore } from "@/app/lib/store/chat"; import { + ChatAudioPayload, ChatMessage, LLMCallRequest, LLMInput, @@ -97,6 +99,7 @@ async function executeChatCall(args: { signal: AbortSignal; }): Promise<{ text: string; + audio: ChatAudioPayload | null; jobId: string; conversationId: string | null; }> { @@ -111,10 +114,11 @@ async function executeChatCall(args: { } const jobId = created.data.job_id; const result = await pollLLMCall(jobId, args.apiKey, { signal: args.signal }); - const text = extractAssistantText(result.llm_response?.response); - const newConversationId = - result.llm_response?.response?.conversation_id ?? args.conversationId; - return { text, jobId, conversationId: newConversationId }; + const response = result.llm_response?.response; + const audio = extractAssistantAudio(response); + const text = audio ? "" : extractAssistantText(response); + const newConversationId = response?.conversation_id ?? args.conversationId; + return { text, audio, jobId, conversationId: newConversationId }; } function checkTextConfig( @@ -267,6 +271,7 @@ export default function ChatPage() { const { text, + audio, jobId, conversationId: newConversationId, } = await executeChatCall({ @@ -280,12 +285,20 @@ export default function ChatPage() { if (newConversationId && newConversationId !== conversationId) { setConversationId(newConversationId); } - updateMessageInStore(assistantMessage.id, { - content: - text || - "(The assistant returned an empty response — try again or pick a different configuration.)", - status: "complete", - }); + if (audio) { + updateMessageInStore(assistantMessage.id, { + content: "", + audio, + status: "complete", + }); + } else { + updateMessageInStore(assistantMessage.id, { + content: + text || + "(The assistant returned an empty response — try again or pick a different configuration.)", + status: "complete", + }); + } finishAbort(); return text || null; } catch (err) { diff --git a/app/(main)/configurations/prompt-editor/page.tsx b/app/(main)/configurations/prompt-editor/page.tsx index 3602bcb..7ecbe21 100644 --- a/app/(main)/configurations/prompt-editor/page.tsx +++ b/app/(main)/configurations/prompt-editor/page.tsx @@ -238,6 +238,9 @@ function PromptEditorContent() { ]); const handleSave = async () => { + const wasNewConfig = !allConfigMeta.find( + (m) => m.name === currentConfigName.trim(), + ); const ok = await saveConfig({ currentConfigName, currentConfigBlob, @@ -248,6 +251,7 @@ function PromptEditorContent() { if (ok) { setHasUnsavedChanges(false); setCommitMessage(""); + if (wasNewConfig) resetEditor(); } }; diff --git a/app/components/chat/ChatMessage.tsx b/app/components/chat/ChatMessage.tsx index 61dc093..4f59fa2 100644 --- a/app/components/chat/ChatMessage.tsx +++ b/app/components/chat/ChatMessage.tsx @@ -115,6 +115,46 @@ function AssistantBody({ ); } +function AssistantAudioPlayer({ + url, + mimeType, +}: { + url: string; + mimeType: string; +}) { + const [error, setError] = useState(false); + + if (error) { + return ( +
+ + + Audio is no longer available (the signed URL may have expired). + +
+ ); + } + + return ( +
+
+ + Voice reply +
+ +
+ ); +} + function AssistantMessage({ message }: { message: ChatMessageType }) { const isPending = message.status === "pending"; const isError = message.status === "error"; @@ -141,7 +181,8 @@ function AssistantMessage({ message }: { message: ChatMessageType }) { } }; - const showToolbar = !isPending && !isError && !!message.content; + const hasAudio = !!message.audio?.url; + const showToolbar = !isPending && !isError && !hasAudio && !!message.content; return (
@@ -149,8 +190,13 @@ function AssistantMessage({ message }: { message: ChatMessageType }) {
- {isPending && !message.content ? ( + {isPending && !message.content && !hasAudio ? ( + ) : hasAudio && message.audio ? ( + ) : ( Waiting for microphone permission…; case "listening": return <>Listening…; case "sending": @@ -113,7 +115,9 @@ export default function VoiceInput({ ? "Tap ↑ to send · ✕ to cancel" : status === "sending" ? "Sending your message…" - : "Preparing microphone…"} + : status === "requesting" + ? "Allow microphone access in the browser prompt to start" + : "Preparing microphone…"}

diff --git a/app/components/prompt-editor/ConfigEditorPane.tsx b/app/components/prompt-editor/ConfigEditorPane.tsx index 18cbcdc..a09788d 100644 --- a/app/components/prompt-editor/ConfigEditorPane.tsx +++ b/app/components/prompt-editor/ConfigEditorPane.tsx @@ -7,7 +7,12 @@ import { ConfigVersionItems, CompletionConfig, } from "@/app/lib/types/configs"; -import { MODEL_OPTIONS, isGpt5Model } from "@/app/lib/models"; +import { + ConfigType, + MODEL_OPTIONS, + getModelsForType, + isGpt5Model, +} from "@/app/lib/models"; import { PROVIDER_TYPES, PROVIDES_OPTIONS } from "@/app/lib/constants"; import GuardrailsSection from "./GuardrailsSection"; import SaveConfigModal from "./SaveConfigModal"; @@ -104,25 +109,39 @@ export default function ConfigEditorPane({ ? allConfigMeta.find((m) => m.name === configName.trim()) : undefined; + const currentType = (configBlob.completion.type || "text") as ConfigType; + const handleProviderChange = (newProvider: string) => { + const candidates = getModelsForType(newProvider, currentType); + const fallback = MODEL_OPTIONS[newProvider]?.[0]?.value ?? ""; + const nextModel = candidates[0]?.value ?? fallback; onConfigChange({ ...configBlob, completion: { ...configBlob.completion, provider: newProvider as CompletionConfig["provider"], - params: { - ...params, - model: - MODEL_OPTIONS[newProvider as keyof typeof MODEL_OPTIONS][0].value, - }, + params: { ...params, model: nextModel }, }, }); }; - const handleTypeChange = (newType: "text" | "stt" | "tts") => { + const handleTypeChange = (newType: ConfigType) => { + const provider = configBlob.completion.provider; + const candidates = getModelsForType(provider, newType); + const currentModel = params.model; + const stillValid = candidates.some((m) => m.value === currentModel); + const nextModel = stillValid + ? currentModel + : (candidates[0]?.value ?? + MODEL_OPTIONS[provider]?.[0]?.value ?? + currentModel); onConfigChange({ ...configBlob, - completion: { ...configBlob.completion, type: newType }, + completion: { + ...configBlob.completion, + type: newType, + params: { ...params, model: nextModel }, + }, }); }; @@ -247,7 +266,7 @@ export default function ConfigEditorPane({