238 lines
8.3 KiB
TypeScript
238 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { use, useMemo, useRef } from "react";
|
|
import { useNarrative } from "../../game-engine/narrative-provider.tsx";
|
|
import { EroticActionSuggestions } from "./chat-actions.tsx";
|
|
import { navigationPromise } from "@bureaudouble/polyfills/navigation";
|
|
import { PaperPlaneIcon } from "@bureaudouble/icons/radix/PaperPlaneIcon";
|
|
import { useGetAssistantTurn } from "../../game-engine/game-state.ts";
|
|
import { useChatHistory } from "../../hooks/use-chat-history.ts";
|
|
import type { NarrativeMessage } from "../../game-engine/narrative-provider.tsx";
|
|
import { Textarea } from "../ui/textarea.tsx";
|
|
import {
|
|
gameStateToVariantState,
|
|
personaChatInputContainerVariants,
|
|
personaInputPlaceholderVariants,
|
|
personaSubmitButtonVariants,
|
|
personaTextareaContainerVariants,
|
|
personaTextareaVariants,
|
|
} from "../../game-engine/persona-styles.ts";
|
|
import cn from "@bureaudouble/outils/cn.ts";
|
|
import { useFieldSizing } from "../../hooks/use-field-sizing.ts";
|
|
import { useClientLocale } from "../../game-engine/locale-provider.tsx";
|
|
import type { ErrorCode } from "../../api/chat/route.ts";
|
|
|
|
const errorCodeToTranslationKey = {
|
|
QUOTA_EXCEEDED: "errorQuotaExceeded",
|
|
PROVIDER_UNAVAILABLE: "errorProviderUnavailable",
|
|
MODEL_NOT_FOUND: "errorModelNotFound",
|
|
UPSTREAM_ERROR: "errorUpstream",
|
|
UNKNOWN: "errorUnknown",
|
|
PARSE_FAILED: "errorParseFailed",
|
|
} as const satisfies Record<ErrorCode | "PARSE_FAILED", string>;
|
|
|
|
export default function ChatInput({ className }: { className?: string }) {
|
|
const { t } = useClientLocale();
|
|
const {
|
|
status,
|
|
sendMessage,
|
|
messages,
|
|
reload,
|
|
isNewChat,
|
|
id,
|
|
gameState,
|
|
localModelProgress,
|
|
errorCode,
|
|
errorDetails,
|
|
clearError,
|
|
autoRetryAttempt,
|
|
autoRetryMax,
|
|
} = useNarrative();
|
|
|
|
const errorMessage = useMemo(() => {
|
|
if (!errorCode) return null;
|
|
if (errorCode === "UPSTREAM_ERROR" && autoRetryAttempt >= autoRetryMax) {
|
|
return t("errorRetriesExhausted", { max: String(autoRetryMax) });
|
|
}
|
|
// Rich quota message with model name and limit
|
|
if (errorCode === "QUOTA_EXCEEDED" && errorDetails?.quotaModel) {
|
|
return t("errorQuotaExceededDetailed", {
|
|
model: errorDetails.quotaModel,
|
|
limit: String(errorDetails.quotaLimit ?? "?"),
|
|
});
|
|
}
|
|
const key = errorCodeToTranslationKey[errorCode];
|
|
return key ? t(key) : t("errorUnknown");
|
|
}, [errorCode, errorDetails, t, autoRetryAttempt, autoRetryMax]);
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
const navigation = use(navigationPromise);
|
|
const { saveMessage, saveChat } = useChatHistory<NarrativeMessage>();
|
|
const { ref: inputRef, triggerManuallyHandleResize: handleResize } =
|
|
useFieldSizing();
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (formRef.current && inputRef.current?.value.trim()) {
|
|
const submitEvent = new Event("submit", {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
formRef.current.dispatchEvent(submitEvent);
|
|
}
|
|
}
|
|
};
|
|
|
|
const onFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
|
|
if (!inputRef.current) return;
|
|
|
|
const message: NarrativeMessage = {
|
|
id: Date.now().toString(),
|
|
role: "user",
|
|
metadata: { createdAt: Date.now() },
|
|
parts: [{ type: "text", text: inputRef.current.value }],
|
|
};
|
|
if (isNewChat) await saveChat(id, [...messages, message]);
|
|
else await saveMessage(id, message);
|
|
sendMessage(message);
|
|
|
|
inputRef.current.value = "";
|
|
handleResize();
|
|
|
|
if (isNewChat) {
|
|
const url = globalThis.location;
|
|
const newSearchParams = new URLSearchParams(url.search);
|
|
if (newSearchParams.get("session-id") !== id) {
|
|
newSearchParams.set("session-id", id);
|
|
navigation.navigate(
|
|
`${url.pathname}?${newSearchParams.toString()}`,
|
|
{ history: "replace" },
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const lastTurn = messages.at(-1)?.role === "assistant"
|
|
? useGetAssistantTurn(messages.at(-1))
|
|
: undefined;
|
|
|
|
const variantState = gameStateToVariantState(gameState);
|
|
|
|
return (
|
|
<form
|
|
ref={formRef}
|
|
onSubmit={onFormSubmit}
|
|
className={cn(personaChatInputContainerVariants(variantState), className)}
|
|
>
|
|
{typeof localModelProgress === "object" && (
|
|
<div className="text-xs text-center text-muted-foreground">
|
|
<p>{localModelProgress?.text}</p>
|
|
<div className="w-full bg-neutral-800 h-1 my-1">
|
|
<div
|
|
className="bg-primary h-1"
|
|
style={{ width: `${localModelProgress.progress * 100}%` }}
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{lastTurn?.callToEroticActions &&
|
|
(
|
|
<EroticActionSuggestions
|
|
variantState={variantState}
|
|
actions={(gameState.personaId === "storytellurx_gateway" &&
|
|
(lastTurn.callToEroticActions?.length ?? 0) === 0)
|
|
? [
|
|
{
|
|
displayText: t("textAction"),
|
|
promptValue: "text",
|
|
style: "system",
|
|
},
|
|
{
|
|
displayText: t("textImageAction"),
|
|
promptValue: "text-image",
|
|
style: "system",
|
|
},
|
|
]
|
|
: lastTurn.callToEroticActions}
|
|
disabled={status !== "ready"}
|
|
onActionClick={(promptValue) => {
|
|
if (status === "ready" && inputRef.current) {
|
|
inputRef.current.value = promptValue;
|
|
handleResize();
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
{autoRetryAttempt > 0 && autoRetryAttempt < autoRetryMax &&
|
|
status !== "streaming" && (
|
|
<div className="px-2 py-1.5 text-xs text-yellow-400 border border-yellow-500/30 bg-yellow-950/20 animate-pulse">
|
|
{t("errorRetrying", {
|
|
attempt: String(autoRetryAttempt),
|
|
max: String(autoRetryMax),
|
|
})}
|
|
</div>
|
|
)}
|
|
{errorMessage && status !== "streaming" && status !== "submitted" && (
|
|
<div className="px-2 py-1.5 text-sm text-red-400 border border-red-500/30 bg-black">
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
<div className={personaTextareaContainerVariants(variantState)}>
|
|
<Textarea
|
|
ref={inputRef}
|
|
onInput={handleResize}
|
|
onKeyDown={handleKeyDown}
|
|
rows={1}
|
|
placeholder={t(personaInputPlaceholderVariants(variantState))}
|
|
aria-label={t("yourMessageInput")}
|
|
disabled={status !== "ready"}
|
|
className={cn(
|
|
"flex-grow resize-none",
|
|
personaTextareaVariants(variantState),
|
|
)}
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={status !== "ready"}
|
|
aria-label={t("sendMessage")}
|
|
className={personaSubmitButtonVariants(variantState)}
|
|
>
|
|
{status === "streaming"
|
|
? <span className="animate-spin text-xs">[*]</span>
|
|
: status === "submitted"
|
|
? <span className="text-xs text-blue-500">[*]</span>
|
|
: status === "error"
|
|
? <span className="text-xs text-red-500">[E]</span>
|
|
: (
|
|
<span className="text-xs">
|
|
{["samantha69", "cybelle", "xixi", "galatee", "nelly"].includes(
|
|
variantState.personaId ?? "",
|
|
)
|
|
? <PaperPlaneIcon className="*:fill-current" />
|
|
: ">"}
|
|
</span>
|
|
)}
|
|
</button>
|
|
{(messages.at(-1)?.role === "user" || status === "error" ||
|
|
errorCode === "PARSE_FAILED") &&
|
|
status !== "streaming" && status !== "submitted" && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
clearError();
|
|
reload();
|
|
}}
|
|
className="p-2 border border-red-500 bg-background hover:bg-white hover:text-black active:bg-neutral-300
|
|
disabled:border-border disabled:text-neutral-600 disabled:hover:bg-background disabled:hover:text-neutral-600
|
|
text-sm h-8 flex items-center justify-center transition-colors duration-150 text-red-500"
|
|
>
|
|
{t("retry")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|