feat: implement basic web app

This commit is contained in:
Henry Li
2026-01-15 23:40:21 +08:00
parent c7d68c6d3f
commit cecc684de1
49 changed files with 4142 additions and 626 deletions

View File

@@ -0,0 +1 @@
export * from "./message-list";

View File

@@ -0,0 +1,329 @@
import type { Message } from "@langchain/langgraph-sdk";
import {
BookOpenTextIcon,
FolderOpenIcon,
GlobeIcon,
LightbulbIcon,
ListTreeIcon,
NotebookPenIcon,
SearchIcon,
SquareTerminalIcon,
WrenchIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import {
ChainOfThought,
ChainOfThoughtContent,
ChainOfThoughtHeader,
ChainOfThoughtSearchResult,
ChainOfThoughtSearchResults,
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
import { MessageResponse } from "@/components/ai-elements/message";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "./utils";
export function MessageGroup({
className,
messages,
isLoading = false,
}: {
className?: string;
messages: Message[];
isLoading?: boolean;
}) {
const steps = useMemo(() => convertToSteps(messages), [messages]);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const [open, setOpen] = useState(false);
const lastStep = steps[steps.length - 1]!;
const { label, icon } = describeStep(lastStep);
return (
<ChainOfThought
className={cn("w-full rounded-lg border px-4 pt-4", className)}
defaultOpen={false}
open={open}
onOpenChange={setOpen}
>
<ChainOfThoughtHeader
className="min-h-6"
icon={
open && steps.length > 1 ? <ListTreeIcon className="size-4" /> : icon
}
>
<div className="flex w-full items-center justify-between">
<div>
<div>
{open && steps.length > 1 ? (
<div>{steps.length} steps</div>
) : (
<MessageResponse rehypePlugins={rehypePlugins}>
{label}
</MessageResponse>
)}
</div>
</div>
<div>
{!open && steps.length > 1 && (
<div className="tet-xs opacity-60">
{steps.length - 1} more step
{steps.length - 1 > 1 ? "s" : ""}
</div>
)}
</div>
</div>
</ChainOfThoughtHeader>
<ChainOfThoughtContent className="pb-4">
{steps.map((step) =>
step.type === "reasoning" ? (
<ChainOfThoughtStep
key={step.id}
label={
<MessageResponse rehypePlugins={rehypePlugins}>
{step.reasoning ?? ""}
</MessageResponse>
}
/>
) : (
<ToolCall key={step.id} {...step} />
),
)}
</ChainOfThoughtContent>
</ChainOfThought>
);
}
function ToolCall({
id,
name,
args,
result,
}: {
id?: string;
name: string;
args: Record<string, unknown>;
result?: string | Record<string, unknown>;
}) {
if (name === "web_search") {
let label: React.ReactNode = "Search for related information";
if (typeof args.query === "string") {
label = (
<div>
Search on the web for{" "}
<span className="font-bold">&quot;{args.query}&quot;</span>
</div>
);
}
return (
<ChainOfThoughtStep key={id} label={label} icon={SearchIcon}>
{Array.isArray(result) && (
<ChainOfThoughtSearchResults>
{result.map((item) => (
<ChainOfThoughtSearchResult key={item.url}>
<a href={item.url} target="_blank" rel="noreferrer">
{item.title}
</a>
</ChainOfThoughtSearchResult>
))}
</ChainOfThoughtSearchResults>
)}
</ChainOfThoughtStep>
);
} else if (name === "web_fetch") {
const url = (args as { url: string })?.url;
let title = url;
if (typeof result === "string") {
const potentialTitle = extractTitleFromMarkdown(result);
if (potentialTitle && potentialTitle.toLowerCase() !== "untitled") {
title = potentialTitle;
}
}
return (
<ChainOfThoughtStep key={id} label="View web page" icon={GlobeIcon}>
<ChainOfThoughtSearchResult>
{url && (
<a href={url} target="_blank" rel="noreferrer">
{title}
</a>
)}
</ChainOfThoughtSearchResult>
</ChainOfThoughtStep>
);
} else if (name === "ls") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = "List folder";
}
const path: string | undefined = (args as { path: string })?.path;
return (
<ChainOfThoughtStep key={id} label={description} icon={FolderOpenIcon}>
{path && (
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (name === "read_file") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = "Read file";
}
const path: string | undefined = (args as { path: string })?.path;
return (
<ChainOfThoughtStep key={id} label={description} icon={BookOpenTextIcon}>
{path && (
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (name === "write_file" || name === "str_replace") {
let description: string | undefined = (args as { description: string })
?.description;
if (!description) {
description = "Write file";
}
const path: string | undefined = (args as { path: string })?.path;
return (
<ChainOfThoughtStep key={id} label={description} icon={NotebookPenIcon}>
{path && (
<ChainOfThoughtSearchResult>{path}</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })
?.description;
if (!description) {
return "Execute command";
}
const command: string | undefined = (args as { command: string })?.command;
return (
<ChainOfThoughtStep
key={id}
label={description}
icon={SquareTerminalIcon}
>
{command && (
<ChainOfThoughtSearchResult>{command}</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
);
} else {
const description: string | undefined = (args as { description: string })
?.description;
return (
<ChainOfThoughtStep
key={id}
label={
description ?? (
<div>
Use tool <b>{name}</b>
</div>
)
}
icon={WrenchIcon}
></ChainOfThoughtStep>
);
}
}
interface GenericCoTStep<T extends string = string> {
id?: string;
type: T;
}
interface CoTReasoningStep extends GenericCoTStep<"reasoning"> {
reasoning: string | null;
}
interface CoTToolCallStep extends GenericCoTStep<"toolCall"> {
name: string;
args: Record<string, unknown>;
result?: string;
}
type CoTStep = CoTReasoningStep | CoTToolCallStep;
function convertToSteps(messages: Message[]): CoTStep[] {
const steps: CoTStep[] = [];
for (const message of messages) {
if (message.type === "ai") {
const reasoning = extractReasoningContentFromMessage(message);
if (reasoning) {
const step: CoTReasoningStep = {
id: message.id,
type: "reasoning",
reasoning: extractReasoningContentFromMessage(message),
};
steps.push(step);
}
for (const tool_call of message.tool_calls ?? []) {
const step: CoTToolCallStep = {
id: tool_call.id,
type: "toolCall",
name: tool_call.name,
args: tool_call.args,
};
const toolCallId = tool_call.id;
if (toolCallId) {
const toolCallResult = findToolCallResult(toolCallId, messages);
if (toolCallResult) {
try {
const json = JSON.parse(toolCallResult);
step.result = json;
} catch {
step.result = toolCallResult;
}
}
}
steps.push(step);
}
}
}
return steps;
}
function describeStep(step: CoTStep): {
label: string;
icon: React.ReactElement;
} {
if (step.type === "reasoning") {
return { label: "Thinking", icon: <LightbulbIcon className="size-4" /> };
} else {
let label: string;
let icon: React.ReactElement = <WrenchIcon className="size-4" />;
if (step.name === "web_search") {
label = "Search on the web";
icon = <SearchIcon className="size-4" />;
} else if (step.name === "web_fetch") {
label = "View web page";
icon = <GlobeIcon className="size-4" />;
} else if (step.name === "ls") {
label = "List folder";
icon = <FolderOpenIcon className="size-4" />;
} else if (step.name === "read_file") {
label = "Read file";
icon = <BookOpenTextIcon className="size-4" />;
} else if (step.name === "write_file" || step.name === "str_replace") {
label = "Write file";
icon = <NotebookPenIcon className="size-4" />;
} else if (step.name === "bash") {
label = "Execute command";
icon = <SquareTerminalIcon className="size-4" />;
} else {
label = `Call tool "${step.name}"`;
icon = <WrenchIcon className="size-4" />;
}
if (typeof step.args.description === "string") {
label = step.args.description;
}
return { label, icon };
}
}

View File

@@ -0,0 +1,71 @@
import type { Message } from "@langchain/langgraph-sdk";
import { memo } from "react";
import {
Message as AIElementMessage,
MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse,
} from "@/components/ai-elements/message";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { cn } from "@/lib/utils";
import { MessageGroup } from "./message-group";
import { extractContentFromMessage, hasReasoning, hasToolCalls } from "./utils";
export function MessageListItem({
className,
message,
messagesInGroup,
isLoading,
}: {
className?: string;
message: Message;
messagesInGroup: Message[];
isLoading?: boolean;
}) {
return (
<AIElementMessage
className={cn("relative", "group/conversation-message", className)}
from={message.type === "human" ? "user" : "assistant"}
>
<MessageContent
className={className}
message={message}
messagesInGroup={messagesInGroup}
isLoading={isLoading}
/>
</AIElementMessage>
);
}
function MessageContent_({
className,
message,
messagesInGroup,
isLoading = false,
}: {
className?: string;
message: Message;
messagesInGroup: Message[];
isLoading?: boolean;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
return (
<AIElementMessageContent className={className}>
{hasReasoning(message) && (
<MessageGroup
className="mb-2"
messages={[message]}
isLoading={isLoading}
/>
)}
<AIElementMessageResponse rehypePlugins={rehypePlugins}>
{extractContentFromMessage(message)}
</AIElementMessageResponse>
{hasToolCalls(message) && (
<MessageGroup messages={messagesInGroup} isLoading={isLoading} />
)}
</AIElementMessageContent>
);
}
const MessageContent = memo(MessageContent_);

View File

@@ -0,0 +1,63 @@
import type { UseStream } from "@langchain/langgraph-sdk/react";
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import type { MessageThreadState } from "@/core/thread";
import { cn } from "@/lib/utils";
import { StreamingIndicator } from "../streaming-indicator";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { MessageListSkeleton } from "./skeleton";
import { groupMessages, hasContent } from "./utils";
export function MessageList({
className,
thread,
}: {
className?: string;
thread: UseStream<MessageThreadState>;
}) {
if (thread.isThreadLoading) {
return <MessageListSkeleton />;
}
return (
<Conversation
className={cn("flex size-full flex-col justify-center pt-2", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-md)">
{groupMessages(
thread.messages,
(groupedMessages, groupIndex, isLastGroup) => {
if (groupedMessages[0] && hasContent(groupedMessages[0])) {
const message = groupedMessages[0];
return (
<MessageListItem
key={message.id}
message={message}
messagesInGroup={groupedMessages}
isLoading={thread.isLoading}
/>
);
}
return (
<MessageGroup
key={groupedMessages[0]!.id}
messages={groupedMessages}
isLoading={thread.isLoading}
/>
);
},
thread.isLoading,
)}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div className="h-40" />
</ConversationContent>
<ConversationScrollButton className="-translate-y-16 backdrop-blur-xs" />
</Conversation>
);
}

View File

@@ -0,0 +1,4 @@
export function MessageListSkeleton() {
// TODO: Add a loading state
return null;
}

View File

@@ -0,0 +1,157 @@
import type { Message } from "@langchain/langgraph-sdk";
export function groupMessages<T>(
messages: Message[],
mapper: (
groupedMessages: Message[],
groupIndex: number,
isLastGroup: boolean,
) => T,
isLoading = false,
): T[] {
if (messages.length === 0) {
return [];
}
const resultsOfGroups: T[] = [];
let currentGroup: Message[] = [];
const lastMessage = messages[messages.length - 1]!;
const yieldCurrentGroup = () => {
if (currentGroup.length > 0) {
const resultOfGroup = mapper(
currentGroup,
resultsOfGroups.length,
currentGroup.includes(lastMessage),
);
if (resultOfGroup !== undefined && resultOfGroup !== null) {
resultsOfGroups.push(resultOfGroup);
}
currentGroup = [];
}
};
let messageIndex = 0;
for (const message of messages) {
if (message.type === "human") {
// Human messages are always shown as a individual group
yieldCurrentGroup();
currentGroup.push(message);
yieldCurrentGroup();
} else if (message.type === "tool") {
// Tool messages are always shown with the assistant messages that contains the tool calls
currentGroup.push(message);
} else if (message.type === "ai") {
if (
hasToolCalls(message) ||
(extractTextFromMessage(message) === "" &&
extractReasoningContentFromMessage(message) !== "" &&
messageIndex === messages.length - 1 &&
isLoading)
) {
// Assistant messages without any content are folded into the previous group
// Normally, these are tool calls (with or without thinking)
currentGroup.push(message);
} else {
// Assistant messages with content (text or images) are shown as a group if they have content
// No matter whether it has tool calls or not
yieldCurrentGroup();
currentGroup.push(message);
}
}
messageIndex++;
}
yieldCurrentGroup();
return resultsOfGroups;
}
export function extractTextFromMessage(message: Message) {
if (typeof message.content === "string") {
return message.content.trim();
}
if (Array.isArray(message.content)) {
return message.content
.map((content) => (content.type === "text" ? content.text : ""))
.join("\n")
.trim();
}
return "";
}
export function extractContentFromMessage(message: Message) {
if (typeof message.content === "string") {
return message.content.trim();
}
if (Array.isArray(message.content)) {
return message.content
.map((content) => {
switch (content.type) {
case "text":
return content.text;
case "image_url":
const imageURL = extractURLFromImageURLContent(content.image_url);
return `![image](${imageURL})`;
default:
return "";
}
})
.join("\n")
.trim();
}
return "";
}
export function extractReasoningContentFromMessage(message: Message) {
if (message.type !== "ai" || !message.additional_kwargs) {
return null;
}
if ("reasoning_content" in message.additional_kwargs) {
return message.additional_kwargs.reasoning_content as string | null;
}
return null;
}
export function extractURLFromImageURLContent(
content:
| string
| {
url: string;
},
) {
if (typeof content === "string") {
return content;
}
return content.url;
}
export function hasContent(message: Message) {
if (typeof message.content === "string") {
return message.content.trim().length > 0;
}
if (Array.isArray(message.content)) {
return message.content.length > 0;
}
return false;
}
export function hasReasoning(message: Message) {
return (
message.type === "ai" &&
typeof message.additional_kwargs?.reasoning_content === "string"
);
}
export function hasToolCalls(message: Message) {
return (
message.type === "ai" && message.tool_calls && message.tool_calls.length > 0
);
}
export function findToolCallResult(toolCallId: string, messages: Message[]) {
for (const message of messages) {
if (message.type === "tool" && message.tool_call_id === toolCallId) {
const content = extractTextFromMessage(message);
if (content) {
return content;
}
}
}
return undefined;
}