← back to Open Seo

src/client/components/chat/ChatMessage.tsx

336 lines

import { type UIMessage } from "ai";
import { useState } from "react";
import {
  AlertTriangle,
  Check,
  ChevronRight,
  Copy,
  Loader2,
  Pencil,
  Undo2,
} from "lucide-react";
import { Markdown } from "@/client/components/Markdown";

// Shared rendering for the chat agents (onboarding + SAM). The chats differ
// only in which tools are available and how tool names become labels
// (resolveToolLabel) plus which message actions their server supports
// (onUndo/onEdit); the UI itself is identical and lives here.

export type ToolLabel = { running: string; done: string };

// Maps a UIMessage tool part type (e.g. "tool-get_serp_results") to its label,
// or null to hide the badge entirely (onboarding hides tools it hasn't curated).
export type ResolveToolLabel = (partType: string) => ToolLabel | null;

// Turn a tool part type ("tool-get_serp_results") into a readable label
// ("Get serp results"). Used for chats that expose too many tools to curate a
// per-tool label map by hand.
export function humanizeToolLabel(partType: string): ToolLabel {
  const name = partType.replace(/^tool-/, "").replace(/_/g, " ");
  const label = name.charAt(0).toUpperCase() + name.slice(1);
  return { running: label, done: label };
}

// Whether an assistant message already shows something — visible text, reasoning,
// or a tool badge. Used to decide when the standalone typing indicator is still
// needed: a running tool badge already reads as progress, so the dots would
// double up.
export function messageHasVisibleContent(message: UIMessage): boolean {
  return message.parts.some(
    (part) =>
      (part.type === "text" && part.text.trim().length > 0) ||
      (part.type === "reasoning" && part.text.trim().length > 0) ||
      part.type.startsWith("tool-"),
  );
}

// Plain text of a message for the clipboard: its visible text parts only (no
// reasoning traces, no tool payloads).
function messageText(message: UIMessage): string {
  return message.parts
    .filter(
      (part): part is Extract<typeof part, { type: "text" }> =>
        part.type === "text",
    )
    .map((part) => part.text)
    .join("\n")
    .trim();
}

function CopyButton({ message }: { message: UIMessage }) {
  const [copied, setCopied] = useState(false);
  return (
    <button
      type="button"
      aria-label="Copy message"
      title="Copy"
      className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
      onClick={() => {
        void navigator.clipboard.writeText(messageText(message));
        setCopied(true);
        setTimeout(() => setCopied(false), 1500);
      }}
    >
      {copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
    </button>
  );
}

// Hover action bar under a message: copy for every message, undo/edit for user
// messages when the chat wires up the handlers (rewinding needs server support,
// so chats opt in per handler).
function MessageActions({
  message,
  onUndo,
  onStartEdit,
}: {
  message: UIMessage;
  onUndo?: () => void;
  onStartEdit?: () => void;
}) {
  return (
    <div
      className={`flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 ${
        message.role === "user" ? "justify-end" : ""
      }`}
    >
      <CopyButton message={message} />
      {onStartEdit ? (
        <button
          type="button"
          aria-label="Edit message"
          title="Edit and resend"
          className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
          onClick={onStartEdit}
        >
          <Pencil className="size-3.5" />
        </button>
      ) : null}
      {onUndo ? (
        <button
          type="button"
          aria-label="Undo from this message"
          title="Undo — remove this message and everything after it"
          className="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
          onClick={onUndo}
        >
          <Undo2 className="size-3.5" />
        </button>
      ) : null}
    </div>
  );
}

// Collapsible "thinking" block for the model's reasoning stream. Collapsed by
// default so the chain-of-thought doesn't bury the answer; while it's still
// streaming it doubles as the progress indicator ("Thinking…" + spinner).
function ReasoningBlock({
  part,
  live,
}: {
  part: Extract<UIMessage["parts"][number], { type: "reasoning" }>;
  live: boolean;
}) {
  const [expanded, setExpanded] = useState(false);
  // Persisted parts can keep a stale state:"streaming" (interrupted or
  // multi-segment turns), so only trust it while the message is actually
  // being generated — otherwise finished replies show hanging spinners.
  const isStreaming = live && part.state === "streaming";
  return (
    <div className="text-base-content/60">
      <button
        type="button"
        onClick={() => setExpanded((open) => !open)}
        className="inline-flex items-center gap-1.5 text-xs hover:text-base-content/80"
      >
        {isStreaming ? (
          <Loader2 className="size-3 animate-spin" />
        ) : (
          <ChevronRight
            className={`size-3 transition-transform ${expanded ? "rotate-90" : ""}`}
          />
        )}
        <span>{isStreaming ? "Thinking…" : "Thought process"}</span>
      </button>
      {expanded ? (
        <div className="mt-1.5 whitespace-pre-wrap border-l-2 border-base-300 pl-3 text-xs text-base-content/50">
          {part.text}
        </div>
      ) : null}
    </div>
  );
}

// A small inline badge for one tool call, rendered in document order inside the
// assistant bubble so the sequence of work stays visible after it completes.
function ToolBadge({
  part,
  live,
  resolveToolLabel,
}: {
  part: UIMessage["parts"][number];
  live: boolean;
  resolveToolLabel: ResolveToolLabel;
}) {
  const labels = resolveToolLabel(part.type);
  if (!labels) return null;
  const state = "state" in part ? part.state : undefined;
  const isDone = state === "output-available";
  // A "running" part in a message that is no longer being generated never
  // finished — the turn was interrupted. Show it as failed, not spinning.
  const isError = state === "output-error" || (!isDone && !live);
  const isRunning = !isError && !isDone;
  return (
    <span
      className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ${
        isError ? "bg-error/10 text-error" : "bg-base-200 text-base-content/70"
      }`}
    >
      {isRunning ? (
        <Loader2 className="size-3 animate-spin" />
      ) : isError ? (
        <AlertTriangle className="size-3" />
      ) : (
        <Check className="size-3" />
      )}
      <span>{isRunning ? `${labels.running}…` : labels.done}</span>
    </span>
  );
}

/**
 * One chat message bubble. User turns render as a right-aligned bubble;
 * assistant turns render each part (reasoning, markdown text, tool badges) in
 * document order, flush with the column. `resolveToolLabel` maps tool part
 * types to labels.
 *
 * Every settled message gets a hover copy button. User messages additionally
 * get undo (rewind the conversation to before this message) and edit (rewind,
 * then resend the edited text) when the chat passes the handlers — both need
 * server support, so chats opt in.
 */
export function ChatMessage({
  message,
  resolveToolLabel,
  streaming,
  onUndo,
  onEdit,
}: {
  message: UIMessage;
  resolveToolLabel: ResolveToolLabel;
  /** True while this message is still being generated: reasoning spinners
   * stay live and the hover actions (copy) are held back until it settles. */
  streaming?: boolean;
  onUndo?: () => void;
  onEdit?: (newText: string) => void;
}) {
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState("");

  if (message.role === "user") {
    if (editing && onEdit) {
      const submit = () => {
        const text = draft.trim();
        setEditing(false);
        if (text && text !== messageText(message)) onEdit(text);
      };
      return (
        <div className="flex flex-col items-end gap-1.5 pl-8 sm:pl-16">
          <textarea
            className="textarea textarea-bordered w-full max-w-xl text-sm"
            rows={Math.min(6, Math.max(2, draft.split("\n").length))}
            value={draft}
            autoFocus
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter" && !event.shiftKey) {
                event.preventDefault();
                submit();
              }
              if (event.key === "Escape") setEditing(false);
            }}
          />
          <div className="flex gap-1.5">
            <button
              type="button"
              className="btn btn-ghost btn-xs"
              onClick={() => setEditing(false)}
            >
              Cancel
            </button>
            <button
              type="button"
              className="btn btn-primary btn-xs"
              onClick={submit}
            >
              Save & resend
            </button>
          </div>
        </div>
      );
    }
    return (
      <div className="group flex flex-col gap-1">
        <div className="flex justify-end pl-8 sm:pl-16">
          <div className="rounded-box rounded-br-sm bg-primary px-4 py-2.5 text-sm text-primary-content">
            {message.parts.map((part, index) =>
              part.type === "text" ? (
                <span key={index} className="whitespace-pre-wrap">
                  {part.text}
                </span>
              ) : null,
            )}
          </div>
        </div>
        <MessageActions
          message={message}
          onUndo={onUndo}
          onStartEdit={
            onEdit
              ? () => {
                  setDraft(messageText(message));
                  setEditing(true);
                }
              : undefined
          }
        />
      </div>
    );
  }

  return (
    <div className="group flex flex-col gap-1">
      <div className="min-w-0 space-y-2 text-sm">
        {message.parts.map((part, index) => {
          if (part.type === "reasoning") {
            return part.text.trim() ? (
              <ReasoningBlock
                key={index}
                part={part}
                live={Boolean(streaming)}
              />
            ) : null;
          }
          if (part.type === "text") {
            return part.text.trim() ? (
              <Markdown key={index}>{part.text}</Markdown>
            ) : null;
          }
          if (part.type.startsWith("tool-")) {
            return (
              <ToolBadge
                key={index}
                part={part}
                live={Boolean(streaming)}
                resolveToolLabel={resolveToolLabel}
              />
            );
          }
          return null;
        })}
      </div>
      {streaming ? null : <MessageActions message={message} />}
    </div>
  );
}