mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 02:04:31 +10:00
refactor(web): let the assistant, theme and ai settings compile with the react compiler
Also guards the composer's Enter for Safari IME, gives the undo-detection effect real dependencies, lets the composer grow with its text, names the API keys table and moves acceptResumeProposals next to the other proposal logic.
This commit is contained in:
@@ -31,6 +31,7 @@ import { client, orpc } from "@/libs/orpc/client";
|
||||
import { Composer, Conversation } from "./conversation";
|
||||
import { ProviderSetup } from "./provider-setup";
|
||||
|
||||
type UsableProvider = ReturnType<typeof useHasUsableAiProvider>["usableProviders"][number];
|
||||
type ThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
|
||||
|
||||
type AssistantPanelProps = {
|
||||
@@ -59,7 +60,6 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) {
|
||||
const [providerId, setProviderId] = useState<string | null>(null);
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const mine = (threads.data ?? []).filter((thread) => belongsTo(thread, document));
|
||||
// Opens the document's latest conversation unless a new one (or another) was asked for.
|
||||
@@ -75,11 +75,12 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) {
|
||||
const start = async (text: string) => {
|
||||
if (starting) return;
|
||||
setStarting(true);
|
||||
const input = {
|
||||
...(document.kind === "letter" ? { coverLetterId: document.id } : { resumeId: document.id }),
|
||||
...(provider ? { aiProviderId: provider.id } : {}),
|
||||
};
|
||||
try {
|
||||
const created = await client.agent.threads.start({
|
||||
...(document.kind === "letter" ? { coverLetterId: document.id } : { resumeId: document.id }),
|
||||
...(provider ? { aiProviderId: provider.id } : {}),
|
||||
});
|
||||
const created = await client.agent.threads.start(input);
|
||||
setPrompt(text);
|
||||
setSelected(created.id);
|
||||
useEditorStore.getState().setAssistantSuggestions(null);
|
||||
@@ -90,9 +91,8 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) {
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Couldn't start the conversation. Try again.` }),
|
||||
});
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
setStarting(false);
|
||||
};
|
||||
|
||||
// A question from ⌘K (or Prepare for next step) starts a conversation as soon as the assistant can.
|
||||
@@ -125,35 +125,14 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) {
|
||||
</h2>
|
||||
|
||||
{usable.length > 0 && (
|
||||
<DropdownMenu open={modelMenuOpen} onOpenChange={setModelMenuOpen}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Model: ${providerLabel}`}
|
||||
className="flex h-7 max-w-[140px] items-center gap-0.5 rounded-md px-2 text-ink-2 text-xs transition-colors hover:bg-hover"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="truncate">{provider?.model ?? providerLabel}</span>
|
||||
<Icon name="expand_more" size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{usable.map((item) => (
|
||||
<DropdownMenuItem key={item.id} onClick={() => void chooseProvider(item.id)}>
|
||||
<span className="grid min-w-0 flex-1">
|
||||
<span className="truncate">{item.label}</span>
|
||||
<span className="truncate text-ink-3 text-xs">{item.model}</span>
|
||||
</span>
|
||||
{item.id === provider?.id && <Icon name="check" size={16} />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void navigate({ to: "/dashboard/settings/ai" })}>
|
||||
<Trans>Connect another…</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ModelMenu
|
||||
open={modelMenuOpen}
|
||||
onOpenChange={setModelMenuOpen}
|
||||
providers={usable}
|
||||
current={provider}
|
||||
label={providerLabel}
|
||||
onChoose={(id) => void chooseProvider(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
@@ -279,6 +258,51 @@ function ConversationLoader({ threadId, document, ...props }: ConversationLoader
|
||||
);
|
||||
}
|
||||
|
||||
type ModelMenuProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providers: readonly UsableProvider[];
|
||||
current: UsableProvider | undefined;
|
||||
label: string;
|
||||
onChoose: (id: string) => void;
|
||||
};
|
||||
|
||||
function ModelMenu({ open, onOpenChange, providers, current, label, onChoose }: ModelMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Model: ${label}`}
|
||||
className="flex h-7 max-w-[140px] items-center gap-0.5 rounded-md px-2 text-ink-2 text-xs transition-colors hover:bg-hover"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="truncate">{current?.model ?? label}</span>
|
||||
<Icon name="expand_more" size={16} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
{providers.map((item) => (
|
||||
<DropdownMenuItem key={item.id} onClick={() => onChoose(item.id)}>
|
||||
<span className="grid min-w-0 flex-1">
|
||||
<span className="truncate">{item.label}</span>
|
||||
<span className="truncate text-ink-3 text-xs">{item.model}</span>
|
||||
</span>
|
||||
{item.id === current?.id && <Icon name="check" size={16} />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => void navigate({ to: "/dashboard/settings/ai" })}>
|
||||
<Trans>Connect another…</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function Notice({ children }: { children: ReactNode }) {
|
||||
return <p className="m-4 rounded-xl bg-sunken p-4 text-ink-2 text-sm">{children}</p>;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { FileUIPart, UIMessage } from "ai";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { eventIteratorToUnproxiedDataStream } from "@orpc/client";
|
||||
import { lastAssistantMessageIsCompleteWithToolCalls, parseJsonEventStream, uiMessageChunkSchema } from "ai";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { agentMessageMetadataSchema } from "@reactive-resume/ai/tools/agent-tool-contracts";
|
||||
import { streamClient } from "@/libs/orpc/client";
|
||||
|
||||
@@ -80,7 +80,9 @@ type UseAssistantChatInput = {
|
||||
*/
|
||||
export function useAssistantChat({ threadId, initialMessages, resume, context, onFinish }: UseAssistantChatInput) {
|
||||
const contextRef = useRef(context);
|
||||
contextRef.current = context;
|
||||
useLayoutEffect(() => {
|
||||
contextRef.current = context;
|
||||
});
|
||||
|
||||
const transport = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ProposeEditsOutput } from "@reactive-resume/ai/tools/agent-tool-contracts";
|
||||
import type { Proposal } from "@reactive-resume/resume/proposals";
|
||||
import type { IconName } from "@reactive-resume/ui/components/icon";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ChatAttachment, MessageContext } from "./chat";
|
||||
@@ -8,7 +9,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { IconButton } from "@reactive-resume/ui/components/icon-button";
|
||||
@@ -18,6 +19,7 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ChangeSet } from "@/features/resume/editor/proposals/proposal-list";
|
||||
import { useEditorStore } from "@/features/resume/editor/store";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { isImeComposing } from "@/libs/keyboard";
|
||||
import { ENTER_CLASS, POP_CLASS } from "@/libs/motion";
|
||||
import { client, orpc } from "@/libs/orpc/client";
|
||||
import { attachmentPart, fileToBase64, transcriptOf, useAssistantChat } from "./chat";
|
||||
@@ -121,6 +123,8 @@ export function Conversation(props: ConversationProps) {
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const recordUndone = useEffectEvent(record);
|
||||
|
||||
// Undoing an accepted edit makes it pending again.
|
||||
useEffect(() => {
|
||||
for (const message of messages) {
|
||||
@@ -129,10 +133,10 @@ export function Conversation(props: ConversationProps) {
|
||||
const undone = toProposals(part, statuses, document).filter(
|
||||
(proposal) => proposal.status === "accepted" && document.stateOf(proposal) === "pending",
|
||||
);
|
||||
if (undone.length > 0) record(message, part, undone, "pending");
|
||||
if (undone.length > 0) recordUndone(message, part, undone, "pending");
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [messages, statuses, document]);
|
||||
|
||||
// Follows the reply while it streams, unless the user scrolled up to read.
|
||||
useEffect(() => {
|
||||
@@ -154,7 +158,7 @@ export function Conversation(props: ConversationProps) {
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-label={t`Conversation`}
|
||||
className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4"
|
||||
className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4"
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
<MessageView
|
||||
@@ -569,10 +573,9 @@ export function Composer(props: ComposerProps) {
|
||||
type: "error",
|
||||
description: getOrpcErrorMessage(error, { fallback: t`Couldn't attach the file.` }),
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
}
|
||||
setUploading(false);
|
||||
if (fileInput.current) fileInput.current.value = "";
|
||||
};
|
||||
|
||||
const chips = [
|
||||
@@ -605,38 +608,24 @@ export function Composer(props: ComposerProps) {
|
||||
{(chips.length > 0 || attachments.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
<RemovableChip
|
||||
key={chip.key}
|
||||
className="flex h-[26px] items-center gap-1 rounded-md bg-sunken ps-2 text-ink-2 text-xs"
|
||||
>
|
||||
<Icon name={chip.icon} size={15} />
|
||||
<span className="max-w-[160px] truncate">{chip.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Don't send ${chip.label}`}
|
||||
onClick={() => onContextChange({ ...context, [chip.key]: false })}
|
||||
className="grid size-5 place-items-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
icon={chip.icon}
|
||||
label={chip.label}
|
||||
maxWidth="max-w-[160px]"
|
||||
removeLabel={t`Don't send ${chip.label}`}
|
||||
onRemove={() => onContextChange({ ...context, [chip.key]: false })}
|
||||
/>
|
||||
))}
|
||||
{attachments.map((attachment) => (
|
||||
<span
|
||||
<RemovableChip
|
||||
key={attachment.id}
|
||||
className="flex h-[26px] items-center gap-1 rounded-md bg-sunken ps-2 text-ink-2 text-xs"
|
||||
>
|
||||
<Icon name="attach_file" size={15} />
|
||||
<span className="max-w-[140px] truncate">{attachment.filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Remove ${attachment.filename}`}
|
||||
onClick={() => setAttachments((current) => current.filter((item) => item.id !== attachment.id))}
|
||||
className="grid size-5 place-items-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
icon="attach_file"
|
||||
label={attachment.filename}
|
||||
maxWidth="max-w-[140px]"
|
||||
removeLabel={t`Remove ${attachment.filename}`}
|
||||
onRemove={() => setAttachments((current) => current.filter((item) => item.id !== attachment.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -653,7 +642,7 @@ export function Composer(props: ComposerProps) {
|
||||
placeholder={t`Ask, or describe a change…`}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
if (event.key === "Enter" && !event.shiftKey && !isImeComposing(event)) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
@@ -662,7 +651,7 @@ export function Composer(props: ComposerProps) {
|
||||
props.onStop();
|
||||
}
|
||||
}}
|
||||
className="max-h-40 min-h-[44px] flex-1 resize-none bg-transparent px-1.5 py-1 text-sm outline-none placeholder:text-ink-3"
|
||||
className="field-sizing-content max-h-40 min-h-[44px] flex-1 resize-none bg-transparent px-1.5 py-1 text-sm outline-none placeholder:text-ink-3"
|
||||
/>
|
||||
{props.threadId && (
|
||||
<>
|
||||
@@ -702,6 +691,32 @@ export function Composer(props: ComposerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type RemovableChipProps = {
|
||||
icon: IconName;
|
||||
label: string;
|
||||
/** The label's max-width class (Tailwind needs the whole class name in the source). */
|
||||
maxWidth: string;
|
||||
removeLabel: string;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
function RemovableChip({ icon, label, maxWidth, removeLabel, onRemove }: RemovableChipProps) {
|
||||
return (
|
||||
<span className="flex h-[26px] items-center gap-1 rounded-md bg-sunken ps-2 text-ink-2 text-xs">
|
||||
<Icon name={icon} size={15} />
|
||||
<span className={cn(maxWidth, "truncate")}>{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={removeLabel}
|
||||
onClick={onRemove}
|
||||
className="grid size-5 place-items-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyTranscript(messages: readonly UIMessage[]) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(transcriptOf(messages, { user: t`You`, assistant: t`Assistant` }));
|
||||
|
||||
@@ -14,7 +14,7 @@ import { applicationsListQueryOptions } from "@/features/applications/queries";
|
||||
import { useLetterEditorStore } from "@/features/letters/store";
|
||||
import { useCurrentResume } from "@/features/resume/builder/draft";
|
||||
import { getSectionName } from "@/features/resume/editor/check/issues";
|
||||
import { acceptResumeProposals } from "@/features/resume/editor/proposals/proposal-list";
|
||||
import { acceptResumeProposals } from "@/features/resume/editor/proposals/proposals";
|
||||
import { describeEntry } from "@/features/resume/editor/write/model";
|
||||
|
||||
/** The document the assistant works on, as the panel needs it. Resumes and letters each provide one. */
|
||||
|
||||
@@ -4,33 +4,19 @@ import type { KeyboardEvent, ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Plural, Trans } from "@lingui/react/macro";
|
||||
import { useId, useState } from "react";
|
||||
import { applyProposal, getProposalState } from "@reactive-resume/resume/proposals";
|
||||
import { getProposalState } from "@reactive-resume/resume/proposals";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useIsResumeLocked, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { useIsResumeLocked } from "@/features/resume/builder/draft";
|
||||
import { ENTER_CLASS, POP_CLASS } from "@/libs/motion";
|
||||
import { useEditorStore } from "../store";
|
||||
import { acceptResumeProposals } from "./proposals";
|
||||
|
||||
/** The visible text of a passage's HTML, for the card. */
|
||||
const passageText = (html: string) =>
|
||||
new DOMParser().parseFromString(html, "text/html").body.textContent?.replaceAll("\u00a0", " ").trim() ?? "";
|
||||
|
||||
/** Applies proposals to the resume as one undo step; the toast's Undo takes them back, and they show as pending again. */
|
||||
export function acceptResumeProposals(proposals: readonly Proposal[]) {
|
||||
useResumeStore.getState().updateResumeData(
|
||||
(draft) => {
|
||||
for (const proposal of proposals) applyProposal(draft, proposal);
|
||||
},
|
||||
{ newStep: true },
|
||||
);
|
||||
toast.add({
|
||||
description: proposals.length === 1 ? t`Edit applied` : t`${proposals.length} edits applied`,
|
||||
actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() },
|
||||
});
|
||||
}
|
||||
|
||||
type ProposalListProps = {
|
||||
proposals: readonly Proposal[];
|
||||
data: ResumeData;
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Proposal } from "@reactive-resume/resume/proposals";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { produce } from "immer";
|
||||
import { getProposalState, readTarget, splitBlock, splitBlocks, writeTarget } from "@reactive-resume/resume/proposals";
|
||||
import {
|
||||
applyProposal,
|
||||
getProposalState,
|
||||
readTarget,
|
||||
splitBlock,
|
||||
splitBlocks,
|
||||
writeTarget,
|
||||
} from "@reactive-resume/resume/proposals";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { useResumeStore } from "@/features/resume/builder/draft";
|
||||
|
||||
// Colours of the page marks (README §5.9), as the PDF needs them: old text struck through in grey, new text on
|
||||
// a pale accent highlight.
|
||||
@@ -59,3 +69,17 @@ export function markProposals(data: ResumeData, proposals: readonly Proposal[]):
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Applies proposals to the resume as one undo step; the toast's Undo takes them back, and they show as pending again. */
|
||||
export function acceptResumeProposals(proposals: readonly Proposal[]) {
|
||||
useResumeStore.getState().updateResumeData(
|
||||
(draft) => {
|
||||
for (const proposal of proposals) applyProposal(draft, proposal);
|
||||
},
|
||||
{ newStep: true },
|
||||
);
|
||||
toast.add({
|
||||
description: proposals.length === 1 ? t`Edit applied` : t`${proposals.length} edits applied`,
|
||||
actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ export function ApiKeysSection() {
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full overflow-hidden rounded-xl text-[13px] starting:opacity-0 outline outline-line transition-opacity duration-standard ease-enter max-sm:block">
|
||||
<caption className="sr-only">
|
||||
<Trans>API keys</Trans>
|
||||
</caption>
|
||||
<thead className="bg-bg text-ink-3 text-xs max-sm:hidden">
|
||||
<tr className="h-9 text-start">
|
||||
<th scope="col" className="ps-3.5 text-start font-medium">
|
||||
|
||||
@@ -89,7 +89,12 @@ type TestState = { ok: boolean; label: string; error: string | null } | null;
|
||||
function useProviderTest() {
|
||||
const queryClient = useQueryClient();
|
||||
const [result, setResult] = useState<TestState>(null);
|
||||
const test = useMutation(orpc.aiProviders.test.mutationOptions({ meta: { noInvalidate: true } }));
|
||||
const test = useMutation(
|
||||
orpc.aiProviders.test.mutationOptions({
|
||||
meta: { noInvalidate: true },
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() }),
|
||||
}),
|
||||
);
|
||||
|
||||
const run = async (id: string) => {
|
||||
setResult(null);
|
||||
@@ -107,8 +112,6 @@ function useProviderTest() {
|
||||
};
|
||||
setResult(next);
|
||||
return next;
|
||||
} finally {
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -265,21 +268,17 @@ function AddProviderDialog({ open, onOpenChange }: AddProviderDialogProps) {
|
||||
|
||||
const save = async () => {
|
||||
setFailure(null);
|
||||
const input = {
|
||||
label: fields.label.trim() || providerLabel(provider),
|
||||
provider,
|
||||
model: fields.model.trim(),
|
||||
baseURL: fields.baseURL.trim(),
|
||||
apiKey: fields.apiKey.trim(),
|
||||
};
|
||||
let tested: Awaited<ReturnType<typeof test.mutateAsync>> | undefined;
|
||||
try {
|
||||
const created = await create.mutateAsync({
|
||||
label: fields.label.trim() || providerLabel(provider),
|
||||
provider,
|
||||
model: fields.model.trim(),
|
||||
baseURL: fields.baseURL.trim(),
|
||||
apiKey: fields.apiKey.trim(),
|
||||
});
|
||||
const tested = await test.mutateAsync({ id: created.id });
|
||||
if (tested.testStatus === "success") {
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
// The provider is saved either way; its row keeps the error and a Test button.
|
||||
setFailure(tested.testError ?? t`The provider didn't answer. Check the key, the model and the base URL.`);
|
||||
}
|
||||
const created = await create.mutateAsync(input);
|
||||
tested = await test.mutateAsync({ id: created.id });
|
||||
} catch (error) {
|
||||
setFailure(
|
||||
getOrpcErrorMessage(error, {
|
||||
@@ -290,8 +289,14 @@ function AddProviderDialog({ open, onOpenChange }: AddProviderDialogProps) {
|
||||
fallback: t`Couldn't save the provider.`,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() });
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() });
|
||||
if (!tested) return;
|
||||
if (tested.testStatus === "success") {
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
// The provider is saved either way; its row keeps the error and a Test button.
|
||||
setFailure(tested.testError ?? t`The provider didn't answer. Check the key, the model and the base URL.`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -401,17 +406,18 @@ function EditProviderForm({ provider, onClose }: EditProviderFormProps) {
|
||||
|
||||
const save = async () => {
|
||||
setFailure(null);
|
||||
let result: Awaited<ReturnType<typeof test.run>> | undefined;
|
||||
try {
|
||||
await update.mutateAsync({ id: provider.id, ...changes });
|
||||
// A new model, key or address needs a fresh test before the app uses it.
|
||||
const result = await test.run(provider.id);
|
||||
if (result.ok) onClose();
|
||||
else setFailure(result.error ?? t`The provider didn't answer.`);
|
||||
result = await test.run(provider.id);
|
||||
} catch (error) {
|
||||
setFailure(getOrpcErrorMessage(error, { fallback: t`Couldn't save the provider.` }));
|
||||
} finally {
|
||||
void invalidate();
|
||||
}
|
||||
void invalidate();
|
||||
if (!result) return;
|
||||
if (result.ok) onClose();
|
||||
else setFailure(result.error ?? t`The provider didn't answer.`);
|
||||
};
|
||||
|
||||
const setEnabled = (enabled: boolean) =>
|
||||
|
||||
@@ -59,8 +59,8 @@ export function ThemeProvider({ children, theme }: Props) {
|
||||
|
||||
if (!playSound) return;
|
||||
|
||||
const soundClip = next === "dark" ? "/sounds/switch-off.mp3" : "/sounds/switch-on.mp3";
|
||||
try {
|
||||
const soundClip = next === "dark" ? "/sounds/switch-off.mp3" : "/sounds/switch-on.mp3";
|
||||
const audio = new Audio(soundClip);
|
||||
await audio.play();
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user