SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
5710dd0 verified
Raw History Blame Contribute Delete
17.4 kB
import { Fragment, useRef, useMemo, useState, useEffect } from "react";
import { IconSend, IconPlayerStop, IconChevronDown, IconPlus } from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ActionMenu } from "../ActionMenu";
import { SlashCommandMenu } from "../SlashCommandMenu";
import { FilePickerMenu } from "../FilePickerMenu";
import { MediaThumbnail } from "../MediaThumbnail";
import { MediaPreviewModal } from "../MediaPreviewModal";
import { BottomToolbar } from "../BottomToolbar";
import { StreamingConfirmDialog } from "../StreamingConfirmDialog";
import { ThinkingButton } from "../ThinkingButton";
import { PlanModeButton } from "../PlanModeButton";
import {
getModelById,
getMediaFallbackModel,
getModelsForMedia,
groupModelsByProvider,
providerDisplayName,
useChatStore,
useSettingsStore,
} from "@/stores";
import { bridge, Events } from "@/services";
import { Content } from "@/lib/content";
import { cn } from "@/lib/utils";
import { useSlashMenu, findActiveToken } from "./hooks/useSlashMenu";
import { useFilePicker } from "./hooks/useFilePicker";
import { useMediaUpload } from "./hooks/useMediaUpload";
import { useClickOutside } from "./hooks/useClickOutside";
import { useInputHistory } from "./hooks/useInputHistory";
import { computeMentionInsert } from "./utils";
interface InputAreaProps {
onAuthAction?: () => void;
}
const SWITCH_CACHE_NOTE =
"Note: Switching models or thinking effort invalidates the existing prompt cache. Start a new conversation to avoid extra token costs.";
function adjustHeight(textarea: HTMLTextAreaElement | null) {
if (!textarea) return;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 140)}px`;
}
export function InputArea({ onAuthAction }: InputAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [text, setText] = useState("");
const [cursorPos, setCursorPos] = useState(0);
const [previewMedia, setPreviewMedia] = useState<string | null>(null);
const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode, messages } = useChatStore();
const { currentModel, thinkingEffort, updateModel, toggleThinking, selectThinkingEffort, models, extensionConfig, getCurrentThinkingMode } = useSettingsStore();
const isProcessing = hasProcessingMedia();
const thinkingMode = getCurrentThinkingMode();
// A switch from a non-empty conversation resends the accumulated context,
// losing the prompt cache — surface the cost note in the switcher dropdowns.
const hasConversationHistory = messages.some((message) => message.role === "user");
const [showPlanModeConfirm, setShowPlanModeConfirm] = useState(false);
const handleTogglePlanMode = () => {
// Turning OFF during streaming needs confirmation — user may want next turn, not current
if (planMode && isStreaming) {
setShowPlanModeConfirm(true);
return;
}
const newState = !planMode;
useChatStore.setState({ planMode: newState }); // optimistic
void bridge.setPlanMode(newState);
};
const handleConfirmExitPlanMode = () => {
useChatStore.setState({ planMode: false });
void bridge.setPlanMode(false);
setShowPlanModeConfirm(false);
};
const mediaReq = useMemo(() => {
const media = getMediaInConversation();
return { image: media.hasImage, video: media.hasVideo };
}, [getMediaInConversation, draftMedia]);
const availableModels = useMemo(() => getModelsForMedia(models, mediaReq), [models, mediaReq]);
const currentModelConfig = getModelById(models, currentModel);
const modelGroups = useMemo(() => groupModelsByProvider(availableModels), [availableModels]);
const showProviderGroups = modelGroups.length > 1;
const currentModelLabel = currentModelConfig === undefined
? "No models available"
: showProviderGroups
? `${currentModelConfig.name} · ${providerDisplayName(currentModelConfig.provider)}`
: currentModelConfig.name;
// Auto-switch model if current model doesn't support required media
useEffect(() => {
if (!mediaReq.image && !mediaReq.video) {
return;
}
const isCurrentModelValid = availableModels.some((m) => m.id === currentModel);
if (isCurrentModelValid) {
return;
}
const fallbackModel = getMediaFallbackModel(availableModels, currentModelConfig);
if (fallbackModel !== undefined) {
updateModel(fallbackModel.id);
}
}, [mediaReq.image, mediaReq.video, currentModel, currentModelConfig, availableModels, updateModel]);
// Restore pending input
useEffect(() => {
if (!pendingInput || isStreaming) {
return;
}
// 只在输入框为空时恢复
if (text.trim()) {
return;
}
const textContent = Content.getText(pendingInput.content);
if (textContent) {
setText(textContent);
setTimeout(() => {
textareaRef.current?.focus();
adjustHeight(textareaRef.current);
}, 0);
}
}, [pendingInput, isStreaming]);
const activeToken = useMemo(() => findActiveToken(text, cursorPos), [text, cursorPos]);
const { handlePaste, handlePickMedia } = useMediaUpload();
const {
handleKey: handleHistoryKey,
add: addToHistory,
reset: resetHistoryIndex,
} = useInputHistory({
text,
setText,
onHeightChange: () => setTimeout(() => { adjustHeight(textareaRef.current); }, 0),
});
function clearInput() {
setText("");
setCursorPos(0);
setTimeout(() => { adjustHeight(textareaRef.current); }, 0);
}
function removeActiveToken() {
if (!activeToken) return;
const newText = text.slice(0, activeToken.start) + text.slice(cursorPos);
const newCursorPos = activeToken.start;
setText(newText);
setCursorPos(newCursorPos);
setTimeout(() => {
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos);
adjustHeight(textareaRef.current);
}, 0);
}
function handleSend() {
if (isProcessing || (!text.trim() && draftMedia.length === 0)) {
return;
}
addToHistory(text);
sendMessage(text);
clearInput();
}
function handleSlashCommand(name: string) {
sendMessage(`/${name}`);
clearInput();
}
function applyMention(filePath: string) {
const { newText, newCursorPos } = computeMentionInsert({
text,
cursorPos,
filePath,
activeToken,
isAppend: false,
});
setText(newText);
setCursorPos(newCursorPos);
setTimeout(() => {
textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos);
textareaRef.current?.focus();
adjustHeight(textareaRef.current);
}, 0);
}
const {
showSlashMenu,
filteredCommands,
selectedIndex: slashSelectedIndex,
setSelectedIndex: setSlashSelectedIndex,
handleSlashMenuKey,
resetSlashMenu,
} = useSlashMenu(activeToken, handleSlashCommand, removeActiveToken);
const {
showFileMenu,
fileItems,
selectedIndex: fileSelectedIndex,
isLoading: isFileLoading,
isStale: isFileStale,
showMediaOption,
setSelectedIndex: setFileSelectedIndex,
handleSelectItem: handleSelectFileItem,
handleFileMenuKey,
resetFilePicker,
} = useFilePicker(
activeToken,
applyMention,
() => {
void handlePickMedia();
},
removeActiveToken,
);
const closeMenus = () => {
if (showSlashMenu || showFileMenu) {
removeActiveToken();
}
};
useClickOutside([textareaRef, menuRef], showSlashMenu || showFileMenu, closeMenus);
useEffect(() => {
resetSlashMenu();
}, [showSlashMenu, resetSlashMenu]);
useEffect(() => {
if (!showFileMenu) {
resetFilePicker();
}
}, [showFileMenu, resetFilePicker]);
useEffect(() => {
const unsub = bridge.on<{ mention: string }>(Events.InsertMention, ({ mention }) => {
setText((prev) => prev + mention + " ");
setTimeout(() => {
textareaRef.current?.focus();
adjustHeight(textareaRef.current);
}, 0);
});
return unsub;
}, []);
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.nativeEvent.isComposing) {
return;
}
if (handleSlashMenuKey(e)) {
return;
}
if (handleFileMenuKey(e)) {
return;
}
if (handleHistoryKey(e)) {
return;
}
if (extensionConfig.useCtrlEnterToSend) {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSend();
}
} else {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
}
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(e.target.value);
setCursorPos(e.target.selectionStart);
resetHistoryIndex();
setTimeout(() => { adjustHeight(textareaRef.current); }, 0);
};
const handleSelect = () => {
setCursorPos(textareaRef.current?.selectionStart ?? 0);
};
function handleAddButtonClick() {
const newText = text + "@";
setText(newText);
setCursorPos(newText.length);
setTimeout(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(newText.length, newText.length);
adjustHeight(textareaRef.current);
}, 0);
}
const hasModels = availableModels.length > 0;
const canSend = (text.trim() || draftMedia.length > 0) && !isProcessing;
return (
<div className="p-2 pt-0! flex flex-col min-h-0">
<BottomToolbar />
<div className="relative shrink-0">
{showSlashMenu && filteredCommands.length > 0 && (
<div ref={menuRef} className="absolute bottom-full left-0 right-0 mb-2 z-10">
<SlashCommandMenu
commands={filteredCommands}
query={activeToken?.query || ""}
selectedIndex={slashSelectedIndex}
onSelect={handleSlashCommand}
onHover={setSlashSelectedIndex}
/>
</div>
)}
{showFileMenu && (
<div ref={menuRef} className="absolute bottom-full left-0 right-0 mb-2 z-10">
<FilePickerMenu
items={fileItems}
selectedIndex={fileSelectedIndex}
isLoading={isFileLoading}
isStale={isFileStale}
showMediaOption={showMediaOption}
onSelectMedia={() => {
void handlePickMedia();
}}
onSelectItem={handleSelectFileItem}
onHover={setFileSelectedIndex}
/>
</div>
)}
<div className="border border-input rounded-md overflow-hidden">
{draftMedia.length > 0 && (
<div className="flex gap-2 p-2 overflow-x-auto">
{draftMedia.map((item) => (
<MediaThumbnail
key={item.id}
src={item.dataUri}
size="sm"
onClick={item.dataUri ? () => setPreviewMedia(item.dataUri!) : undefined}
onRemove={() => removeDraftMedia(item.id)}
/>
))}
</div>
)}
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
onSelect={handleSelect}
onPaste={handlePaste}
placeholder={isStreaming ? "Add a follow-up…" : "Ask Kimi Code… (/ commands · @ files · Alt+K code)"}
className={cn(
"w-full min-h-12 max-h-35 px-2.5 py-1.5 text-xs leading-relaxed",
"bg-transparent resize-none outline-none border-none overflow-y-auto",
"placeholder:text-muted-foreground",
)}
/>
<div className="flex items-center justify-between px-1.5 pb-1.5">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="xs"
className="gap-0.5 text-accent-foreground border-0! h-6 px-1.5 min-w-0 max-w-[calc(100%-4rem)]"
disabled={isStreaming || !hasModels}
>
{/* Name stays readable longest: the dimmed provider
suffix carries a higher shrink factor so space
pressure truncates it before the model name, and
below 520px it drops out entirely (still shown in
the tooltip and the dropdown) — a narrow sidebar
has no room for both. */}
<span className="flex min-w-0 items-center text-xs">
<span className="truncate">{currentModelConfig?.name ?? "No models available"}</span>
{currentModelConfig !== undefined && showProviderGroups && (
<span className="shrink-[3] truncate text-muted-foreground max-[520px]:hidden">
{" · "}{providerDisplayName(currentModelConfig.provider)}
</span>
)}
</span>
{hasModels && <IconChevronDown className="size-3.5 shrink-0" />}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>{currentModelLabel}</TooltipContent>
</Tooltip>
<DropdownMenuContent className="w-52!" align="start">
{modelGroups.map((group, groupIndex) => (
<Fragment key={group.provider}>
{showProviderGroups && <DropdownMenuLabel>{group.label}</DropdownMenuLabel>}
{group.models.map((model) => (
<DropdownMenuItem
key={model.id}
onClick={() => updateModel(model.id)}
className={cn("text-xs px-3 py-1.5 cursor-pointer", currentModel === model.id && "bg-accent")}
>
{model.name}
</DropdownMenuItem>
))}
{showProviderGroups && groupIndex < modelGroups.length - 1 && <DropdownMenuSeparator />}
</Fragment>
))}
{hasConversationHistory && (
<>
<DropdownMenuSeparator />
<div className="px-3 py-1.5 text-[10px] leading-snug whitespace-normal text-muted-foreground">
{SWITCH_CACHE_NOTE}
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<ThinkingButton
mode={thinkingMode}
effort={thinkingEffort}
efforts={currentModelConfig?.support_efforts}
alwaysOn={currentModelConfig?.capabilities.includes("always_thinking")}
disabled={isStreaming}
cacheNote={hasConversationHistory ? SWITCH_CACHE_NOTE : undefined}
onToggle={toggleThinking}
onSelectEffort={selectThinkingEffort}
/>
<PlanModeButton active={planMode} onToggle={handleTogglePlanMode} />
</div>
<div className="flex items-center gap-2 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon-xs" onClick={handleAddButtonClick} className="text-muted-foreground">
<IconPlus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Add files or media</TooltipContent>
</Tooltip>
<ActionMenu onAuthAction={onAuthAction} />
{isStreaming ? (
<Button variant="destructive" size="icon-xs" onClick={abort}>
<IconPlayerStop className="size-3.5" />
</Button>
) : (
<Button variant="default" size="icon-xs" onClick={handleSend} disabled={!canSend}>
<IconSend className="size-3.5" />
</Button>
)}
</div>
</div>
</div>
</div>
<MediaPreviewModal src={previewMedia} onClose={() => setPreviewMedia(null)} />
<StreamingConfirmDialog
open={showPlanModeConfirm}
onOpenChange={setShowPlanModeConfirm}
title="Exit Plan Mode"
description="The agent is still working. Exiting plan mode now will affect the current turn. Are you sure you want to exit plan mode immediately?"
confirmLabel="Exit Now"
onConfirm={handleConfirmExitPlanMode}
/>
</div>
);
}