open-swe/apps/web/src/components/thread/messages/human.tsx
Brace Sproul 0db1a02d13
feat: Add internal messages state (#141)
* feat: Add internal messages state

* update frontend

* disable parallel tool calls
2025-06-12 20:00:51 +00:00

159 lines
4.5 KiB
TypeScript

import { useStreamContext } from "@/providers/Stream";
import { Message } from "@langchain/langgraph-sdk";
import { useState } from "react";
import { getContentString } from "../utils";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import { BranchSwitcher, CommandBar } from "./shared";
import { MultimodalPreview } from "@/components/thread/MultimodalPreview";
import { isBase64ContentBlock } from "@/lib/multimodal-utils";
import { BaseMessage } from "@langchain/core/messages";
function EditableContent({
value,
setValue,
onSubmit,
}: {
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
onSubmit: () => void;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
onSubmit();
}
};
return (
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
className="focus-visible:ring-0"
/>
);
}
export function HumanMessage({
message,
isLoading,
}: {
message: Message;
isLoading: boolean;
}) {
const thread = useStreamContext();
const meta = thread.getMessagesMetadata(message);
const parentCheckpoint = meta?.firstSeenState?.parent_checkpoint;
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState("");
const contentString = getContentString(message.content);
const handleSubmitEdit = () => {
setIsEditing(false);
const newMessage = {
type: "human",
content: value,
} as unknown as BaseMessage;
thread.submit(
{ messages: [newMessage], internalMessages: [newMessage] },
{
checkpoint: parentCheckpoint,
streamMode: ["values"],
optimisticValues: (prev) => {
const values = meta?.firstSeenState?.values;
if (!values) return prev;
return {
...values,
messages: [
...(values.messages ?? []),
newMessage,
] as unknown as BaseMessage[],
};
},
config: {
recursion_limit: 400,
},
},
);
};
return (
<div
className={cn(
"group ml-auto flex items-center gap-2",
isEditing && "w-full max-w-xl",
)}
>
<div className={cn("flex flex-col gap-2", isEditing && "w-full")}>
{isEditing ? (
<EditableContent
value={value}
setValue={setValue}
onSubmit={handleSubmitEdit}
/>
) : (
<div className="flex flex-col gap-2">
{/* Render images and files if no text */}
{Array.isArray(message.content) && message.content.length > 0 && (
<div className="flex flex-wrap items-end justify-end gap-2">
{message.content.reduce<React.ReactNode[]>(
(acc, block, idx) => {
if (isBase64ContentBlock(block)) {
acc.push(
<MultimodalPreview
key={idx}
block={block}
size="md"
/>,
);
}
return acc;
},
[],
)}
</div>
)}
{/* Render text if present, otherwise fallback to file/image name */}
{contentString ? (
<p className="bg-muted ml-auto w-fit rounded-3xl px-4 py-2 whitespace-pre-wrap">
{contentString}
</p>
) : null}
</div>
)}
<div
className={cn(
"ml-auto flex items-center gap-2 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
isEditing && "opacity-100",
)}
>
<BranchSwitcher
branch={meta?.branch}
branchOptions={meta?.branchOptions}
onSelect={(branch) => thread.setBranch(branch)}
isLoading={isLoading}
/>
<CommandBar
isLoading={isLoading}
content={contentString}
isEditing={isEditing}
setIsEditing={(c) => {
if (c) {
setValue(contentString);
}
setIsEditing(c);
}}
handleSubmitEdit={handleSubmitEdit}
isHumanMessage={true}
/>
</div>
</div>
</div>
);
}