Skip to content

003 RSC

https://sdk.vercel.ai/docs/getting-started/nextjs-app-router#create-a-server-action

https://github.com/vercel/ai/discussions/1384

3.1

3.1.1 actions.tsx

/app/actions.tsx

tsx
'use server'

import { createStreamableValue } from 'ai/rsc'
import { CoreMessage, streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

export async function continueConversation(messages: CoreMessage[]) {
  const result = await streamText({
    model: openai('gpt-4-turbo'),
    messages
  })

  const stream = createStreamableValue(result.textStream)
  return stream.value
}

3.1.2 前端界面

我们使用 /app/alt/page.tsx

tsx
'use client'

import { type CoreMessage } from 'ai'
import { useState } from 'react'
import { continueConversation } from '../actions'
import { readStreamableValue } from 'ai/rsc'

export default function Chat() {
  const [messages, setMessages] = useState<CoreMessage[]>([])
  const [input, setInput] = useState('')

  const handleChat = async () => {
    const newMessages: CoreMessage[] = [
      ...messages,
      { content: input, role: 'user' }
    ]

    setMessages(newMessages)
    setInput('')

    const result = await continueConversation(newMessages)

    for await (const content of readStreamableValue(result)) {
      setMessages([
        ...newMessages,
        {
          role: 'assistant',
          content: content as string
        }
      ])
    }
  }

  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {messages.map((m, i) => (
        <div key={i} className="whitespace-pre-wrap">
          {m.role === 'user' ? 'User: ' : 'AI: '}
          {m.content as string}
        </div>
      ))}

      <form
        action={handleChat}
      >
        <input
          className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
          value={input}
          placeholder="Say something..."
          onChange={e => setInput(e.target.value)}
        />
      </form>
    </div>
  )
}

3.2 同步返回数据

3.2.1 actions.tsx

tsx
'use server'

import { createStreamableValue } from 'ai/rsc'
import { CoreMessage, streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

export async function continueConversation(messages: CoreMessage[]) {
  const result = await streamText({
    model: openai('gpt-4-turbo'),
    messages
  })

  const stream = createStreamableValue(result.textStream)
  const data = { test: 'hello' }

  return {
    message: stream.value,
    data
  }
}

3.2.2 页面

tsx
'use client'

import { type CoreMessage } from 'ai'
import { useState } from 'react'
import { continueConversation } from '../actions'
import { readStreamableValue } from 'ai/rsc'

export default function Chat() {
  const [messages, setMessages] = useState<CoreMessage[]>([])
  const [input, setInput] = useState('')
  const [data, setData] = useState<any>()

  const handleChat = async () => {
    const newMessages: CoreMessage[] = [
      ...messages,
      { content: input, role: 'user' }
    ]

    setMessages(newMessages)
    setInput('')

    const result = await continueConversation(newMessages)

    setData(result.data)

    for await (const content of readStreamableValue(result.message)) {
      setMessages([
        ...newMessages,
        {
          role: 'assistant',
          content: content as string
        }
      ])
    }
  }

  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {data && <pre>{JSON.stringify(data, null, 2)}</pre>}
      {messages.map((m, i) => (
        <div key={i} className="whitespace-pre-wrap">
          {m.role === 'user' ? 'User: ' : 'AI: '}
          {m.content as string}
        </div>
      ))}

      <form
        action={handleChat}
      >
        <input
          className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
          value={input}
          placeholder="Say something..."
          onChange={e => setInput(e.target.value)}
        />
      </form>
    </div>
  )
}

以下为之前版本

3. 方式二:使用 RSC 与 ai/rsc

在 Vercel AI SDK 更新到 3.0 版后,它推荐使用 RSC(服务端组件)来调用模型,并提供了几个 Hook 来协助我们在页面前端完成这一任务:

  • useActions
  • useUIState
  • useAIState

我们可以看到,它引入了两个新的概念:AIState(对话消息)、UIState(界面呈现)。开发者可以针对不同的消息定制相应的显示 UI。

摘译相关介绍文档如下:

引入这两个状态后,服务器端AI操作、客户端UI呈现被分离开来。这种分离让开发者可以方便地、安全地维护AI状态。与此同时,UIState 让 RSC 能以流式传输到 UI。

  • AIState 是 LLM 需要读取的对话上下文,以 JSON 形式表示。 AIState 可以在服务器和客户端上访问或修改。
  • UIState 是应用程序用来显示用户界面的内容。它是完全客户端状态(与 useState 非常相似),可以保存由 LLM 返回的数据和用户界面元素。这种状态可以是任何东西,但无法在服务端上访问。

参考:https://sdk.vercel.ai/docs/concepts/ai-rsc

使用 RSC 后,它的整体概念图如下:

3.1 创建一个 RSC 实例

我们将 RSC 放在 /lib/airsc 目录下,新建相关的目录,并创建文件: /lib/airsc/actions.ts

我们将之拆解为如下四步:

第一步:导入与准备

tsx
import { OpenAI } from "openai";
import { createAI, getMutableAIState, render } from "ai/rsc";

type UIStateType = {
  role: 'user' | 'assistant' | 'system' | 'function';
  id: number;
  display: React.ReactNode;
}

type AIStateType = {
    role: 'user' | 'assistant' | 'system' | 'function';
    content: string;
    id?: string;
    name?: string;
}

第二步:创建 OpenAI 实例

tsx
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

第三步:创建 Actions 函数,这里为 submitUserMessage()

这里的核心是 render() 函数,它根据 LLM 的返回生成页面组件。

在缺省情况下, render() 将使用React Fragment <> 标签包装 LLM 回应的文本,可参考链接中的文档

tsx
async function submitUserMessage(userInput: string): Promise<UIStateType>{
  'use server';

  const aiState = getMutableAIState<typeof AI>();

  // Update the AI state with the new user message.
  aiState.update([
    ...aiState.get(),
    {
      role: 'user',
      content: userInput,
    },
  ]);

  // The `render()` creates a generated, streamable UI.
  const ui = render({
    model: 'gpt-3.5-turbo-0125',
    provider: openai,
    messages: [
      { role: 'system', content: 'You are a flight assistant' },
      ...aiState.get()
    ],
  })

  return {
    id: Date.now(),
    role: 'assistant',
    display: ui,
  };
}

疑问

  • 难道每次都附加上了 system prompt吗?
  • 这个对话有上下文记忆吗?

如果你需要定制文本回复,将它以自定义的格式显示,可以在render()中增加 text 键值如下:

tsx
text: ({ content, done }) => {
  // When it's the final content, mark the state as done and ready for the client to access.
  if (done) {
    console.log("in text:", content)
    aiState.done([
      ...aiState.get(),
      {
        role: "assistant",
        content
      }
    ]);
  }

  return <p>{content}</p>
},

第四步:使用 createAI() 创建可供调用的 AI 实例

tsx
// Define the initial state of the AI. It can be any JSON object.
const initialAIState: AIStateType[] = [];

// The initial UI state that the client will keep track of, which contains the message IDs and their UI nodes.
const initialUIState: UIStateType[] = [];

// AI is a provider you wrap your application with so you can access AI and UI state in your components.
export const AI = createAI({
  actions: {
    submitUserMessage
  },
  // Each state can be any shape of object, but for chat applications
  // it makes sense to have an array of messages. Or you may prefer something like { id: number, messages: Message[] }
  initialUIState,
  initialAIState
});

3.2 将前端与服务端连起来

相关的界面组件需要包裹在 <AI> context provider 之内。你需要按这里「将前端与服务端连起来」在合适的地方进行设置。

新建 /rsc/layout.ts,代码如下:

tsx
import { AI } from "@/lib/airsc/actions";

export default function Layout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
        <AI>
          {children}
        </AI>
  );
}

3.3 编写界面组件 RSCChat

如下界面组件的功能是:

  • 它显示对话消息和输入框;
  • 在用户输入时,进行如下三步操作
    1. 将用户消息加入消息串 (使用setMessages)
    2. 调用服务端 Action:submitUserMessage()
    3. 将返回消息加入消息串(使用setMessages

useChat 对比

这里暂时没有 useChatisLoadingstop等较为方便的帮助函数。因此,如果仅仅使用文本消息对话,方式一仍较简便。

/components/rsc-chat.tsx

tsx
'use client'

import { useState } from 'react';
import { useUIState, useActions } from "ai/rsc";
import type { AI } from '@/lib/airsc/actions';

export default function RSCChat () {
  const [inputValue, setInputValue] = useState('');

  const [messages, setMessages] = useUIState<typeof AI>();
  const { submitUserMessage } = useActions<typeof AI>();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    // Add user message to UI state
    setMessages((currentMessages) => [
      ...currentMessages,
      {
        id: Date.now(),
        role: 'user',
        display: <p>{inputValue}</p>,
      },
    ]);

    // Submit and get response message
    const responseMessage = await submitUserMessage(inputValue);

    // Add user message to UI state
    setMessages((currentMessages) => [
      ...currentMessages,
      responseMessage,
    ]);

    setInputValue('');
  };

  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
        <div>
        {
          // View messages in UI state
          messages.map((message) => (
            <div key={message.id} className='flex'>
            <div className='pr-2'>
              {message.role === 'user' ? 'User: ' : 'AI: '}
            </div>
            <div >{message.display}</div>
            </div>
          ))
        }
        </div>

        <form onSubmit={handleSubmit}>
          <input
            className="fixed bottom-0 w-full max-w-md p-2 mb-8 border border-gray-300 rounded shadow-xl"
            placeholder="Send a message..."
            value={inputValue}
            onChange={(event) => {
              setInputValue(event.target.value)
            }}
          />
        </form>
    </div>
  )
}

说明

如上示例代码参考了 https://sdk.vercel.ai/docs/concepts/ai-rsc 。但进行了如下改进:

submitUserMessage() 的返回值中增加了 role。以便在前端更方便地显示成对话的形式。

返回值的类型定义是:

tsx
type UIStateType = {
  role: 'user' | 'assistant' | 'system' | 'function';
  id: number;
  display: React.ReactNode;
}

3.4 将对话组件显示到页面上

新建 /app/rsc/page.tsx,在其中显示 <RSCChat /> 页面组件。

tsx
import  RSCChat  from '@/components/rsc-chat';

export default function Page() {
  return <RSCChat />;
}

Alang.AI - Make Great AI Applications