storytellurx-v1/app/components/chat/chat-input.tsx
Elias Rhouzlane 0de2b3420e fix: go
2026-02-10 12:10:55 +01:00

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>
);
}