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

329 lines
9.7 KiB
Markdown

# 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:
```typescript
"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:
```typescript
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:
```typescript
{
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:
```typescript
{
type: "tool-result",
toolName: "createTask",
toolCallId: "call_abc123",
output: {
type: "json",
value: { success: true, taskId: "task_xyz" } // Nested in value
}
}
```
### Unified Handling Pattern
```typescript
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":
```typescript
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
```tsx
// Do not expose an AI action based only on elder read permission.
{permissions.includes("elder:read") ? <button>AI </button> : null}
```
#### Correct
```tsx
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 |