Files
teatea-pension/.trellis/spec/frontend/ai-sdk-integration.md

9.7 KiB

AI SDK Frontend Integration

1. Overview

This guide covers frontend integration with the Vercel AI SDK using @ai-sdk/react. Key topics include:

  • Using @ai-sdk/react for React integration
  • Streaming chat with the useChat hook
  • Tool call handling with proper format detection

2. Basic Chat with useChat

The useChat hook provides a simple interface for chat functionality:

"use client";

import { useChat } from "@ai-sdk/react";

export function ChatPanel() {
  const { messages, input, handleInputChange, handleSubmit, status } = useChat({
    api: "/api/chat",
  });

  return (
    <div>
      {messages.map((message) => (
        <div key={message.id}>
          <strong>{message.role}:</strong> {message.content}
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Type a message..."
          disabled={status === "streaming"}
        />
        <button type="submit" disabled={status === "streaming"}>
          Send
        </button>
      </form>
    </div>
  );
}

3. Custom Transport with oRPC

When using oRPC instead of standard fetch:

import { useChat } from "@ai-sdk/react";
import { eventIteratorToStream } from "@orpc/client";
import { orpcClient } from "@/lib/orpc-client";

export function ChatPanel({ sessionId }: { sessionId: string }) {
  const { messages, sendMessage, status } = useChat({
    id: sessionId,
    transport: {
      async sendMessages(options) {
        return eventIteratorToStream(
          await orpcClient.chat.send(
            {
              sessionId,
              messages: options.messages,
            },
            { signal: options.abortSignal }
          )
        );
      },
      reconnectToStream() {
        throw new Error("Reconnect not supported");
      },
    },
  });

  // ... rest of component
}

4. Tool Calls Handling

CRITICAL: Tool calls have TWO different formats that must both be handled:

Format 1: Real-time Streaming

During streaming, tool results appear as:

{
  type: "tool-createTask",  // tool-{toolName}
  toolCallId: "call_abc123",
  state: "output-available",
  input: { title: "...", priority: "high" },
  output: { success: true, taskId: "task_xyz" }  // Direct object
}

Format 2: History Restore

When loading from history/database:

{
  type: "tool-result",
  toolName: "createTask",
  toolCallId: "call_abc123",
  output: {
    type: "json",
    value: { success: true, taskId: "task_xyz" }  // Nested in value
  }
}

Unified Handling Pattern

import { useChat } from "@ai-sdk/react";
import { useEffect, useState, useRef } from "react";

export function AssistantPanel({ sessionId }: { sessionId: string }) {
  const [createdItems, setCreatedItems] = useState<Map<string, CreatedItem>>(new Map());
  const toolCallsRef = useRef<Map<string, string>>(new Map());

  const { messages, status } = useChat({
    id: sessionId,
    transport: { /* ... */ },

    // Handle real-time tool results
    onData: (dataPart) => {
      const payload = typeof dataPart === "object" && "json" in dataPart
        ? (dataPart as { json: unknown }).json
        : dataPart;

      if (typeof payload === "object" && payload !== null && "type" in payload) {
        const { type, data } = payload as { type: string; data: any };

        if (type === "tool-output-available" || type === "tool-result") {
          const { toolCallId, output } = data;
          const toolName = toolCallsRef.current.get(toolCallId);

          if (toolName === "createTask" && output?.success) {
            setCreatedItems((prev) => {
              if (prev.has(toolCallId)) return prev;
              return new Map(prev).set(toolCallId, {
                id: output.taskId,
                title: output.title,
              });
            });
          }
        }
      }
    },
  });

  // Handle history restore
  useEffect(() => {
    messages.forEach((message) => {
      if (message.role !== "assistant") return;

      const parts = (message as any).parts || [];
      parts.forEach((part: any) => {
        // Match both formats
        const isRealTime = part.type === "tool-createTask" && part.state === "output-available";
        const isRestored = part.type === "tool-result" && part.toolName === "createTask";

        if ((isRealTime || isRestored) && part.output) {
          const key = part.toolCallId || message.id;

          // Extract output (handle nested structure)
          const rawOutput = part.output;
          const output = rawOutput?.type === "json" && rawOutput?.value
            ? rawOutput.value
            : rawOutput;

          if (output?.success) {
            setCreatedItems((prev) => {
              if (prev.has(key)) return prev;
              return new Map(prev).set(key, {
                id: output.taskId,
                title: output.title,
              });
            });
          }
        }
      });
    });
  }, [messages, status]);

  return (
    <div>
      {messages.map((message) => (
        <MessageBubble key={message.id} message={message} />
      ))}

      {/* Display created items */}
      {Array.from(createdItems.values()).map((item) => (
        <CreatedItemCard key={item.id} item={item} />
      ))}
    </div>
  );
}

5. Tool Call State Lifecycle

During streaming, tool parts go through these states:

State Description
input-streaming Tool input is being generated
input-available Complete input ready
output-available Tool executed, result available
output-error Tool execution failed

6. Displaying Thought Process

Show users what the AI is "thinking":

const [thoughtSteps, setThoughtSteps] = useState<ThoughtStep[]>([]);

// In onData handler
if (type === "tool-input-start" || type === "tool-call") {
  const { toolCallId, toolName, input } = data;
  toolCallsRef.current.set(toolCallId, toolName);

  setThoughtSteps((prev) => [
    ...prev,
    { id: toolCallId, toolName, status: "pending", input },
  ]);
}

if (type === "tool-output-available") {
  setThoughtSteps((prev) =>
    prev.map((step) =>
      step.id === toolCallId
        ? { ...step, status: "done", result: output }
        : step
    )
  );
}

Scenario: Elder AI Analysis Panel MVP

1. Scope / Trigger

  • Trigger: UI surfaces that render elder AI analysis, knowledge retrieval results, or AI history.
  • Current project contract: the MVP elder workflow is a fixed structured analysis panel, not chat and not streaming.
  • Do not use @ai-sdk/react hooks for this MVP panel; use the existing project API result shape through fetch until a generated client exists for these routes.

2. Signatures

  • Elder row action:
    • Visible only when current permissions include both ai:read and elder:read.
    • Opens ElderAiAnalysisDialog with width="wide".
  • Client APIs:
    • GET /api/ai/elders/[id]/analyses -> { success, reason, history }
    • POST /api/ai/elders/[id]/analyses -> { success, reason, analysis }
    • GET /api/ai/knowledge -> { success, reason, entries }
    • Knowledge mutations -> { success, reason, entry }

3. Contracts

  • Render completed analysis from fixed fields:
    • overallRiskLevel
    • summary
    • keyFindings[]
    • recommendations[]
    • dataGaps[]
    • citations[]
    • confidence
    • modelSummary
  • Render restricted: true history items as placeholders only; do not assume result or citations exist.
  • Render failed history from sanitized errorCategory / errorReason; never display raw provider errors.
  • The MVP UI must not provide one-click business mutations or confirmable drafts from recommendations.

4. Validation & Error Matrix

  • Missing ai:read or elder:read -> hide row action.
  • API failure -> show reason in the dialog or settings page message area.
  • restricted: true history -> show an access-restricted placeholder.
  • status === "failed" -> show failed badge and sanitized reason.
  • Empty history -> show empty state.
  • Generation pending -> disable generate button and show loading label.

5. Good/Base/Bad Cases

  • Good: authorized manager opens dialog, generates analysis, sees latest completed result, citations, data gaps, and history list.
  • Base: generation fails because provider is not configured; user sees a structured failure message and failed history remains visible.
  • Bad: viewer without ai:read sees an AI 分析 row action.
  • Bad: frontend renders recommendations as buttons that create care tasks, alert handling notes, or health review records.

6. Tests Required

  • Component or route smoke coverage for hidden AI action when permissions are missing.
  • History rendering coverage for completed, failed, restricted, and empty states.
  • Knowledge settings coverage for read-only users and knowledge:manage users.
  • Build check must include both unscoped /app/settings/knowledge and scoped /app/[organizationSlug]/settings/knowledge.

7. Wrong vs Correct

Wrong

// Do not expose an AI action based only on elder read permission.
{permissions.includes("elder:read") ? <button>AI 分析</button> : null}

Correct

const canUseAi = permissions.includes("ai:read") && permissions.includes("elder:read");
{canUseAi ? <Button onClick={() => setAiTarget(elder)}>AI 分析</Button> : null}

7. Best Practices Summary

Rule Description
Handle both tool formats Real-time and history restore
Use toolCallId as key Correlate calls across formats
Use useRef for toolName mapping Avoid React state timing issues
onData for real-time UI useEffect for history restore
Show thought process Better UX for tool-heavy flows