branch:
app.tsx
22166 bytesRaw
import { useEffect, useRef, useState } from "react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
import TextareaAutosize from "react-textarea-autosize";
import { GearIcon, CaretDownIcon } from "@phosphor-icons/react";
import { Button } from "@cloudflare/kumo";
import Footer from "./components/Footer";
import Header from "./components/Header";
import { SparkleIcon, WorkersAILogo } from "./components/Icons";
import { McpServers } from "./components/McpServers";
import UnifiedModelSelector from "./components/UnifiedModelSelector";
import ViewCodeModal from "./components/ViewCodeModal";
import { ToolCallCard } from "./components/ToolCallCard";
import { ReasoningCard } from "./components/ReasoningCard";
import { isToolUIPart, type UIMessage } from "ai";
import { useAgent } from "agents/react";
import type { MCPServersState } from "agents";
import { nanoid } from "nanoid";
import type { Playground, PlaygroundState } from "./server";
import type { Model } from "./models";
import type { McpServersComponentState } from "./components/McpServers";
import { Streamdown } from "streamdown";

const STORAGE_KEY = "playground_session_id";
const MAX_MCP_LOGS = 200;

const DEFAULT_PARAMS = {
  model: "@cf/moonshotai/kimi-k2.5",
  temperature: 0,
  stream: true,
  system:
    "You are a helpful assistant that can do various tasks using MCP tools."
};

const DEFAULT_EXTERNAL_MODELS: Record<string, string> = {
  openai: "openai/gpt-5.2",
  anthropic: "anthropic/claude-sonnet-4-5-20250929",
  google: "google-ai-studio/gemini-3-pro-preview",
  xai: "xai/grok-4-1-fast-reasoning"
};

const DEFAULT_MCP_STATUS: McpServersComponentState = {
  servers: [],
  tools: [],
  prompts: [],
  resources: []
};

function getOrCreateSessionId(): string {
  let sessionId = localStorage.getItem(STORAGE_KEY);
  if (!sessionId) {
    sessionId = nanoid();
    localStorage.setItem(STORAGE_KEY, sessionId);
  }
  return sessionId;
}

const App = () => {
  const [codeVisible, setCodeVisible] = useState(false);
  const [settingsVisible, setSettingsVisible] = useState(false);
  const [parametersOpen, setParametersOpen] = useState(false);
  const [models, setModels] = useState<Model[]>([]);
  const [isLoadingModels, setIsLoadingModels] = useState(true);
  const [params, setParams] = useState<PlaygroundState>({
    ...DEFAULT_PARAMS,
    useExternalProvider: false,
    externalProvider: "openai",
    authMethod: "provider-key"
  });

  const [mcp, setMcp] = useState<McpServersComponentState>(DEFAULT_MCP_STATUS);

  const [mcpLogs, setMcpLogs] = useState<
    Array<{ timestamp: number; status: string; serverUrl?: string }>
  >([]);

  const [sessionId, setSessionId] = useState<string>(() =>
    getOrCreateSessionId()
  );

  const agent = useAgent<Playground, PlaygroundState>({
    agent: "playground",
    name: `Cloudflare-AI-Playground-${sessionId}`,
    onError(event) {
      console.error("[App] onError callback triggered with event:", event);
    },
    onStateUpdate(state: PlaygroundState) {
      setParams(state);
    },
    onMcpUpdate(mcpState: MCPServersState) {
      const servers = Object.entries(mcpState.servers || {}).map(
        ([id, server]) => ({
          id,
          name: server.name,
          url: server.server_url,
          state: server.state,
          error: server.error
        })
      );

      setMcp({
        servers,
        tools: mcpState.tools || [],
        prompts: mcpState.prompts || [],
        resources: mcpState.resources || []
      });

      for (const server of servers) {
        if (server.state) {
          setMcpLogs((prev) => {
            const next = [
              ...prev,
              {
                timestamp: Date.now(),
                status: server.state,
                serverUrl: server.url
              }
            ];
            return next.length > MAX_MCP_LOGS
              ? next.slice(-MAX_MCP_LOGS)
              : next;
          });
        }
      }
    }
  });

  // ── State update helper ──
  // Builds the full state from current params, then applies overrides.
  const updateState = (updates: Partial<PlaygroundState>) => {
    agent.setState({
      model: params.useExternalProvider
        ? params.externalModel || params.model
        : params.model,
      temperature: params.temperature,
      stream: params.stream,
      system: params.system,
      useExternalProvider: params.useExternalProvider,
      externalProvider: params.externalProvider,
      externalModel: params.externalModel,
      authMethod: params.authMethod,
      providerApiKey: params.providerApiKey,
      gatewayAccountId: params.gatewayAccountId,
      gatewayId: params.gatewayId,
      gatewayApiKey: params.gatewayApiKey,
      ...updates
    });
  };

  const [agentInput, setAgentInput] = useState("");

  useEffect(() => {
    const getModels = async () => {
      try {
        const models = await agent.stub.getModels();
        setModels(models as Model[]);
      } finally {
        setIsLoadingModels(false);
      }
    };
    getModels();
  }, [agent.stub]);

  const handleAgentSubmit = async (
    e: React.FormEvent | React.KeyboardEvent | React.MouseEvent,
    extraData: Record<string, unknown> = {}
  ) => {
    e.preventDefault();
    if (!agentInput.trim()) return;

    const message = agentInput;
    setAgentInput("");

    await sendMessage(
      { role: "user", parts: [{ type: "text", text: message }] },
      { body: extraData }
    );
  };

  const { messages, clearHistory, status, sendMessage, stop } = useAgentChat<
    PlaygroundState,
    UIMessage<{ createdAt: string }>
  >({
    agent,
    experimental_throttle: 50
  });

  const loading = status === "submitted";
  const streaming = status === "streaming";

  const handleReset = () => {
    const newSessionId = nanoid();
    localStorage.setItem(STORAGE_KEY, newSessionId);
    clearHistory();
    setSessionId(newSessionId);
    setMcp(DEFAULT_MCP_STATUS);
    setMcpLogs([]);
  };

  const messageElement = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (messageElement.current && messages.length > 0) {
      messageElement.current.scrollTop = messageElement.current.scrollHeight;
    }
  }, [messages]);

  const activeModelName = params.useExternalProvider
    ? params.externalModel || params.model
    : (params.model ?? DEFAULT_PARAMS.model);
  const activeModel = params.useExternalProvider
    ? undefined
    : models.find((model) => model.name === activeModelName);

  return (
    <main className="w-full h-full bg-kumo-elevated md:px-6">
      <ViewCodeModal
        params={params}
        messages={messages}
        visible={codeVisible}
        handleHide={(e) => {
          e.stopPropagation();
          setCodeVisible(false);
        }}
      />

      <div className="h-full max-w-[1400px] mx-auto items-start md:pb-[168px]">
        <Header onSetCodeVisible={setCodeVisible} />

        <div className="flex h-full md:pb-8 items-start md:flex-row flex-col">
          {/* ── Left sidebar ── */}
          <div className="md:w-1/3 w-full h-full md:overflow-auto bg-kumo-base md:rounded-md shadow-md md:block z-10">
            <div className="bg-ai h-[3px]" />

            {/* ── Title bar ── */}
            <div className="flex items-center px-3 pt-3 pb-2">
              <span className="text-sm font-semibold text-kumo-default">
                Workers AI Playground
              </span>
              <div className="ml-2">
                <WorkersAILogo />
              </div>
              <div className="ml-auto flex items-center gap-1">
                <Button variant="secondary" size="sm" onClick={handleReset}>
                  Reset
                </Button>
                <Button
                  variant="secondary"
                  size="sm"
                  className="md:hidden"
                  onClick={() => setSettingsVisible(!settingsVisible)}
                  icon={<GearIcon size={16} />}
                  title="Settings"
                />
              </div>
            </div>

            {/* ── Model selector ── */}
            <section className="px-3 pb-2">
              <UnifiedModelSelector
                workersAiModels={models}
                activeWorkersAiModel={activeModel}
                isLoadingWorkersAi={isLoadingModels}
                useExternalProvider={params.useExternalProvider || false}
                externalProvider={params.externalProvider || "openai"}
                externalModel={params.externalModel}
                authMethod={params.authMethod || "provider-key"}
                providerApiKey={params.providerApiKey}
                gatewayAccountId={params.gatewayAccountId}
                gatewayId={params.gatewayId}
                gatewayApiKey={params.gatewayApiKey}
                onModeChange={(useExternal) => {
                  const selectedModel = useExternal
                    ? DEFAULT_EXTERNAL_MODELS[
                        params.externalProvider || "openai"
                      ] || params.model
                    : params.model || DEFAULT_PARAMS.model;
                  updateState({
                    model: selectedModel,
                    useExternalProvider: useExternal,
                    externalModel: useExternal ? selectedModel : undefined
                  });
                }}
                onWorkersAiModelSelect={(model) => {
                  updateState({
                    model: model ? model.name : DEFAULT_PARAMS.model,
                    useExternalProvider: false,
                    externalModel: undefined
                  });
                }}
                onExternalProviderChange={(provider) => {
                  const selectedModel =
                    DEFAULT_EXTERNAL_MODELS[provider] || params.model;
                  updateState({
                    model: selectedModel,
                    useExternalProvider: true,
                    externalProvider: provider,
                    externalModel: selectedModel
                  });
                }}
                onExternalModelSelect={(modelId) => {
                  updateState({
                    model: modelId,
                    useExternalProvider: true,
                    externalModel: modelId
                  });
                }}
                onAuthMethodChange={(method) => {
                  updateState({
                    useExternalProvider: true,
                    authMethod: method
                  });
                }}
                onProviderApiKeyChange={(key) => {
                  updateState({
                    useExternalProvider: true,
                    authMethod: "provider-key",
                    providerApiKey: key
                  });
                }}
                onGatewayAccountIdChange={(accountId) => {
                  updateState({
                    useExternalProvider: true,
                    authMethod: "gateway",
                    gatewayAccountId: accountId
                  });
                }}
                onGatewayIdChange={(gwId) => {
                  updateState({
                    useExternalProvider: true,
                    authMethod: "gateway",
                    gatewayId: gwId
                  });
                }}
                onGatewayApiKeyChange={(apiKey) => {
                  updateState({
                    useExternalProvider: true,
                    authMethod: "gateway",
                    gatewayApiKey: apiKey
                  });
                }}
              />
            </section>

            <div className="bg-ai h-px mx-3 opacity-25" />

            {/* ── Parameters (collapsible) ── */}
            <section className="px-3 py-2">
              <button
                type="button"
                className="flex items-center justify-between w-full group"
                onClick={() => setParametersOpen(!parametersOpen)}
              >
                <span className="text-xs font-semibold text-kumo-secondary uppercase tracking-wide">
                  Parameters
                </span>
                <CaretDownIcon
                  size={14}
                  className={`text-kumo-secondary transition-transform ${parametersOpen ? "rotate-180" : ""}`}
                />
              </button>

              {(parametersOpen || settingsVisible) && (
                <div className="mt-2 space-y-3">
                  <div>
                    <label
                      htmlFor="system-message"
                      className="font-medium text-xs block mb-1 text-kumo-default"
                    >
                      System Message
                    </label>
                    <TextareaAutosize
                      id="system-message"
                      className="w-full p-2 text-sm border border-kumo-line rounded-md resize-none bg-kumo-base text-kumo-default hover:bg-kumo-tint focus:outline-none focus:ring-1 focus:ring-kumo-ring"
                      minRows={2}
                      value={params.system}
                      onChange={(e) => updateState({ system: e.target.value })}
                    />
                  </div>

                  <div>
                    <label
                      htmlFor="temperature"
                      className="font-medium text-xs block mb-1 text-kumo-default"
                    >
                      Temperature
                    </label>
                    <div className="flex items-center gap-2">
                      <input
                        id="temperature"
                        className="w-full appearance-none cursor-pointer bg-ai rounded-full h-1.5 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#901475]"
                        type="range"
                        min={0}
                        max={1}
                        step={0.1}
                        value={params.temperature}
                        onChange={(e) =>
                          updateState({
                            temperature: Number.parseFloat(e.target.value)
                          })
                        }
                      />
                      <span className="text-xs text-kumo-default w-8 text-right tabular-nums">
                        {params.temperature.toFixed(1)}
                      </span>
                    </div>
                  </div>
                </div>
              )}
            </section>

            <div className="bg-ai h-px mx-3 opacity-25" />

            {/* ── MCP Servers ── */}
            <McpServers agent={agent} mcpState={mcp} mcpLogs={mcpLogs} />
          </div>

          {/* ── Chat panel ── */}
          <div
            ref={messageElement}
            className="md:w-2/3 w-full h-full md:ml-6 md:rounded-lg md:shadow-md bg-kumo-base relative overflow-auto flex flex-col"
          >
            <div className="bg-ai h-[3px] hidden md:block" />
            <ul className="pb-6 px-6 pt-6">
              {messages.map((message) => {
                const renderedParts = message.parts
                  .map((part, i) => {
                    if (part.type === "text") {
                      if (!part.text || part.text.trim() === "") return null;

                      return (
                        <li
                          key={i}
                          className="mb-3 flex items-start border-b border-b-kumo-line w-full py-2"
                        >
                          <div className="mr-3 w-[80px]">
                            <button
                              type="button"
                              className={`px-3 py-2 bg-orange-500/15 hover:bg-orange-500/25 text-kumo-default rounded-lg text-sm capitalize cursor-pointer ${
                                (streaming || loading) && "pointer-events-none"
                              }`}
                            >
                              {message.role}
                            </button>
                          </div>
                          <div className="relative grow">
                            <Streamdown
                              className={`sd-theme rounded-md p-3 w-full resize-none mt-[-6px] hover:bg-kumo-tint ${
                                (streaming || loading) && "pointer-events-none"
                              }`}
                              controls={false}
                              isAnimating={
                                streaming &&
                                message === messages[messages.length - 1]
                              }
                            >
                              {part.text}
                            </Streamdown>
                          </div>
                        </li>
                      );
                    }

                    if (part.type === "reasoning") {
                      if (!part.text || part.text.trim() === "") return null;
                      return (
                        <li key={i} className="mb-3 w-full">
                          <ReasoningCard part={part} />
                        </li>
                      );
                    }

                    if (isToolUIPart(part)) {
                      return (
                        <li key={i} className="mb-3 w-full">
                          <ToolCallCard part={part} />
                        </li>
                      );
                    }

                    if (
                      part.type === "file" &&
                      part.mediaType.startsWith("image/")
                    ) {
                      return (
                        <li key={i} className="mb-3 w-full">
                          <img
                            className="max-w-md mx-auto rounded-lg"
                            src={part.url}
                            alt="Tool call response"
                          />
                        </li>
                      );
                    }

                    return null;
                  })
                  .filter(Boolean);

                if (renderedParts.length === 0) return null;
                return <div key={message.id}>{renderedParts}</div>;
              })}

              {(loading || streaming) &&
              (messages[messages.length - 1].role !== "assistant" ||
                messages[messages.length - 1].parts.length === 0) ? (
                <li className="mb-3 flex items-start border-b border-b-kumo-line w-full py-2">
                  <div className="mr-3 w-[80px]">
                    <button
                      type="button"
                      className="px-3 py-2 bg-orange-500/15 hover:bg-orange-500/25 text-kumo-default rounded-lg text-sm capitalize cursor-pointer pointer-events-none"
                    >
                      Assistant
                    </button>
                  </div>
                  <div className="relative grow flex items-end min-h-[36px]">
                    <div className="rounded-md p-3 w-full hover:bg-kumo-tint pointer-events-none flex items-end gap-1 pb-2">
                      <div
                        className="size-1 rounded-full bg-kumo-inactive animate-bounce"
                        style={{ animationDelay: "0s" }}
                      />
                      <div
                        className="size-1 rounded-full bg-kumo-inactive animate-bounce"
                        style={{ animationDelay: "0.1s" }}
                      />
                      <div
                        className="size-1 rounded-full bg-kumo-inactive animate-bounce"
                        style={{ animationDelay: "0.2s" }}
                      />
                    </div>
                  </div>
                </li>
              ) : null}
            </ul>

            {/* ── Input bar ── */}
            <div className="sticky mt-auto bottom-0 left-0 right-0 bg-kumo-base flex items-center p-5 border-t border-t-kumo-line gap-4">
              <div className="flex-1">
                <TextareaAutosize
                  className="rounded-md p-3 w-full resize-none border border-kumo-line bg-kumo-base text-kumo-default hover:border-kumo-ring focus:outline-none focus:ring-2 focus:ring-kumo-ring focus:border-transparent disabled:bg-kumo-control disabled:cursor-not-allowed"
                  placeholder="Enter a message..."
                  value={agentInput}
                  disabled={loading || streaming}
                  onChange={(e) => setAgentInput(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === "Enter" && !e.shiftKey) {
                      e.preventDefault();
                      handleAgentSubmit(e);
                    }
                  }}
                />
              </div>
              <Button
                variant="secondary"
                onClick={() => clearHistory()}
                disabled={streaming || loading}
              >
                Clear
              </Button>
              {loading || streaming ? (
                <Button variant="destructive" onClick={stop}>
                  Stop
                </Button>
              ) : (
                <button
                  type="button"
                  disabled={!agentInput.trim()}
                  onClick={(e) => handleAgentSubmit(e)}
                  className={`bg-ai-loop bg-size-[200%_100%] hover:animate-gradient-background ${
                    !agentInput.trim() ? "opacity-50 cursor-not-allowed" : ""
                  } text-white rounded-md shadow-md py-2 px-6 flex items-center`}
                >
                  Run
                  <div className="ml-2 mt-[2px]">
                    <SparkleIcon />
                  </div>
                </button>
              )}
            </div>
          </div>
        </div>

        <Footer />
      </div>
    </main>
  );
};

export default App;