Skip to content

Latest commit

 

History

History
982 lines (792 loc) · 39.4 KB

ui-message-stream.md

File metadata and controls

982 lines (792 loc) · 39.4 KB

UI Message Stream

简体中文 | English

本文档面向需要在 Halo 插件后端接入聊天界面的调用者。

本文是便于全文搜索的 UI Message 单页参考。按任务阅读时,建议从 SDK UI 主题文档 开始;wire 格式可直接查 Stream Protocol。

什么时候使用

API 适合场景 数据形态
fullStream() 后端编排、调试、审计、直接消费模型事件 TextStreamPart
UIMessageStream 把模型增量输出返回给前端 UIMessageChunk
UIMessage 保存和复用聊天消息 聚合后的消息和 parts

简单判断:

  • 只在后端处理模型事件:继续用 fullStream()。
  • 要返回给前端聊天界面:用 UIMessageStreamResponse。
  • 要保存对话并支持下次继续:保存 UIMessage<M>。

最小后端流程

一个后端聊天接口通常只需要做四件事:

  1. 接收 UIMessageChatRequest<M>。
  2. 解析 LanguageModel。
  3. 调用 UIMessageChatHandlers.streamText(...)。
  4. 返回 UIMessageStreamResponse.body(),并在 onFinish 保存消息。
record ChatMetadata(String conversationId) {
}

public Mono<ServerResponse> chat(ServerRequest request) {
    return request.bodyToMono(new ParameterizedTypeReference<
            UIMessageChatRequest<ChatMetadata>>() {
        })
        .flatMap(chatRequest -> aiModelService()
            .flatMap(service -> service.languageModel(modelName))
            .map(model -> UIMessageChatHandlers.streamText(model, chatRequest, options -> options
                .metadataSupplier(() -> new ChatMetadata(chatRequest.id()))
                .serializer(chunk -> objectMapper.writeValueAsString(chunk))
                .request(builder -> builder
                    .system("You are concise.")
                    .temperature(0.2)
                    .maxRetries(2))
                .onFinish(finish -> saveMessages(chatRequest.id(), finish.messages())))))
        .flatMap(chat -> ServerResponse.ok()
            .headers(headers -> headers.setAll(chat.response().headers()))
            .contentType(MediaType.TEXT_EVENT_STREAM)
            .body(chat.response().body(), String.class));
}

response.headers() 会自动加入协议标识:

X-Halo-AI-UI-Message-Stream: v1

body() 会按 SSE 发送 JSON 数据块,并在正常结束时追加:

data: [DONE]

WebFlux 中有两种返回方式,二选一即可。

第一种,直接返回已经编码好的 SSE 字符串:

return ServerResponse.ok()
    .headers(headers -> headers.setAll(response.headers()))
    .contentType(MediaType.TEXT_EVENT_STREAM)
    .body(response.body(), String.class);

第二种,使用结构化流自己构造 ServerSentEvent:

Flux<ServerSentEvent<String>> events = response.stream().chunks()
    .map(chunk -> ServerSentEvent.builder(serializer.apply(chunk)).build())
    .concatWithValues(ServerSentEvent.builder("[DONE]").build());

return ServerResponse.ok()
    .headers(headers -> headers.setAll(response.headers()))
    .contentType(MediaType.TEXT_EVENT_STREAM)
    .body(events, new ParameterizedTypeReference<ServerSentEvent<String>>() {
    });

不要把 response.body() 再包成 ServerSentEvent。body() 已经包含 data: 前缀; 再次包装会生成 data: data: {...},前端按 JSON 解析时会报错。

请求格式

UIMessageChatRequest<M> 是框架无关的聊天请求模型:

{
    "id": "chat-1",
    "messages": [],
    "trigger": "submit-message",
    "messageId": null
}
字段 含义
id 调用方自己的会话或请求标识
messages 前端或数据库传回的 UIMessage 列表
trigger submit-message 或 regenerate-message
messageId 重新生成时必填,指向已有 assistant 消息

Java 端枚举为 UIMessageChatTrigger.SUBMIT_MESSAGE 和 UIMessageChatTrigger.REGENERATE_MESSAGE。如果需要解析字符串,使用 UIMessageChatTrigger.fromValue(...)。

HTTP 传输解析

API 包不绑定 JSON 框架,也不绑定 WebFlux。HTTP 请求进入你的插件后,先用 Jackson、Gson 或框架能力把 JSON 解析成 Map / List,再交给 UIMessageTransportCodec 转成强类型对象:

Map<String, Object> payload = objectMapper.readValue(body,
    new TypeReference<Map<String, Object>>() {
    });

UIMessageChatRequest<Map<String, Object>> chatRequest =
    UIMessageTransportCodec.chatRequestFromMap(payload);

如果 metadata 使用自己的类型,传入映射函数:

record ChatMetadata(String conversationId, String status) {
}

UIMessageChatRequest<ChatMetadata> chatRequest =
    UIMessageTransportCodec.chatRequestFromMap(payload, value ->
        objectMapper.convertValue(value, ChatMetadata.class));

需要把服务端对象写回传输结构时,使用对应的 toMap 方法:

Map<String, Object> messageMap =
    UIMessageTransportCodec.messageToMap(message);

Map<String, Object> requestMap =
    UIMessageTransportCodec.chatRequestToMap(chatRequest);

UIMessageTransportCodec 只负责结构转换。未知消息片段类型、非法角色、非法触发类型、 缺少必填字段会抛出 InvalidUIMessageException;HTTP 接口通常应把它转换成 400 Bad Request。业务语义校验仍使用 UIMessageValidators。

Vue 前端接入

Vue 前端可以使用 @halo-dev/ai-foundation-sdk 直接消费 Halo UIMessage SSE 协议:

import { DefaultChatTransport, useChat } from "@halo-dev/ai-foundation-sdk";

const chat = useChat({
    id: "conversation-1",
    transport: new DefaultChatTransport({
        api: "/apis/example.halo.run/v1alpha1/chat/stream",
    }),
});

await chat.sendMessage({ text: "你好" });

DefaultChatTransport 会向后端发送 UIMessageChatRequest:

{
    "id": "conversation-1",
    "messages": [],
    "trigger": "submit-message",
    "messageId": null
}

响应必须是 Halo UIMessage SSE:

Content-Type: text/event-stream
X-Halo-AI-UI-Message-Stream: v1

每个 SSE data: 是一个 JSON UIMessageChunk,正常结束时发送 data: [DONE]。 如果响应带有 X-Halo-AI-UI-Message-Stream,前端会校验它必须为 v1。

useChat 暴露 messages、status、error、isLoading,以及 sendMessage、 regenerate、stop、setMessages、clearError、addToolOutput、 addToolApprovalResponse、rejectToolCall。 同一个 id 的多个 useChat 调用会共享消息状态。addToolOutput、 addToolApprovalResponse 和 rejectToolCall 会从已有 assistant 消息片段 中补齐工具名称、toolCallId 等上下文,通常应优先从 useChat 返回值调用。

发送图文消息

如果用户选择的是本地文件,调用方可以使用 SDK 提供的 helper 把浏览器 File / FileList 转成 UI Message 的 FilePart,再通过 sendMessage({ text, files }) 发送给后端:

import { DefaultChatTransport, filePartsFromFiles, useChat, type FilePart } from "@halo-dev/ai-foundation-sdk";
import { ref } from "vue";

const input = ref("");
const files = ref<FilePart[]>([]);

const chat = useChat({
    id: "conversation-1",
    transport: new DefaultChatTransport({
        api: "/apis/example.halo.run/v1alpha1/chat/stream",
    }),
});

async function onFileChange(event: Event) {
    const fileList = (event.target as HTMLInputElement).files;
    if (!fileList?.length) {
        return;
    }
    files.value = await filePartsFromFiles(fileList);
}

async function send() {
    await chat.sendMessage({
        text: input.value,
        files: files.value,
    });
    input.value = "";
    files.value = [];
}

对应的 Vue 模板可以很简单:

<template>
    <input v-model="input" placeholder="请看看图片中有什么" />
    <input type="file" accept="image/*" multiple @change="onFileChange" />
    <button :disabled="chat.isLoading.value" @click="send">发送</button>
</template>

filePartsFromFiles(fileList) 会读取文件内容并生成 base64 data,同时尽量保留文件名和 MIME 类型:

await chat.sendMessage({
    text: "请看看图片中有什么",
    files: [
        {
            id: "file-1",
            fileId: "file-1",
            title: "photo.png",
            mediaType: "image/png",
            data: "base64...",
        },
    ],
});

如果文件已经有可访问的 URL,也可以不读取本地文件,直接构造 file part:

await chat.sendMessage({
    text: "请看看图片中有什么",
    files: [
        {
            id: "image-1",
            fileId: "image-1",
            title: "photo.png",
            mediaType: "image/png",
            url: "https://example.com/photo.png",
        },
    ],
});

前端发送的 files 会进入用户消息的 parts,后端通过 UIMessageChatHandlers.streamText(...) 调用语言模型时会把这些文件片段一并转换为模型输入。 调用方仍应根据自己的业务场景限制文件类型、数量、大小和来源;如果当前模型不支持图片识别, 后端应返回能力不支持的错误,而不是静默丢弃文件。

音频识别也使用同一套 files 入口。调用方可以在模型选择器上使用 requiredFeatures: ["audio-input"] 筛选音频识别模型,上传控件再按业务需要限制 accept="audio/*"。

前端可以为流式接收阶段配置运行时 schema 钩子:

import { DefaultChatTransport, useChat } from "@halo-dev/ai-foundation-sdk";

const chat = useChat({
    id: "conversation-1",
    transport: new DefaultChatTransport({
        api: "/apis/example.halo.run/v1alpha1/chat/stream",
    }),
    messageMetadataSchema: {
        safeParse: (value) =>
            value && typeof value === "object" && !Array.isArray(value)
                ? { success: true, data: value as Record<string, unknown> }
                : { success: false, error: { message: "metadata must be an object" } },
    },
    dataPartSchemas: {
        status: {
            "~standard": {
                validate: (value) =>
                    typeof value === "string" ? { value } : { issues: [{ message: "status must be a string" }] },
            },
        },
    },
});

messageMetadataSchema 会校验合并后的流式 metadata,而不是单个 message-metadata 更新。dataPartSchemas 按 data 名称配置,例如 data-status 使用 status 作为键。只有持久化的 data-* 数据块会被校验和保存为 schema 解析后的值;transient data 仍只通过 onData 传给调用方,不写入消息。

schema 钩子只在前端接收流时生效,不会校验 setMessages、构造时传入的历史消息或手动创建的 消息。schema 失败会设置 error、触发 onError,并让 onFinish 收到 isError: true。这些前端校验用于保护 UI 状态,不能替代后端的 UIMessageValidators、HTTP 输入校验或业务权限校验。

自定义读取 UIMessage 流

标准聊天界面应优先使用 useChat 或框架无关的 Chat。如果调用方已经自己管理 HTTP 请求、消息状态、Pinia 状态存储、Web Worker 或其他非 Vue 运行时,只需要读取已有 Halo UIMessage SSE 并聚合 assistant 消息,可以使用 readUIMessageStream。

import { readUIMessageStream, type UIMessage } from "@halo-dev/ai-foundation-sdk";

const response = await fetch("/apis/example.halo.run/v1alpha1/chat/stream", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
        id: "conversation-1",
        messages,
        trigger: "submit-message",
    }),
});

if (!response.ok) {
    throw new Error(await response.text());
}

const result = await readUIMessageStream({
    response,
    messageId: "assistant-1",
    dataPartSchemas: {
        status: {
            safeParse: (value) =>
                typeof value === "string"
                    ? { success: true, data: value }
                    : { success: false, error: { message: "status must be a string" } },
        },
    },
    onChunk(chunk) {
        console.debug("raw chunk", chunk);
    },
    onMessage(message: UIMessage) {
        updateAssistantMessage(message);
    },
    onData(part) {
        console.debug("data", part.name, part.data);
    },
    onToolCall(part) {
        console.debug("tool call", part.toolName, part.input);
    },
    onFinish(event) {
        console.debug(event.status, event.terminal);
    },
});

readUIMessageStream 只读取已有流。调用方仍负责 fetch、请求 body、headers、 credentials、response.ok 和非流式错误响应处理。传入 Response 时,读取器只校验 Halo UIMessage 流协议标识和 response.body,然后读取 SSE。

回调语义:

  • onChunk 在协议和 schema 校验前触发,用于日志或审计,不代表数据块已经被接受。
  • onMessage 在可见 assistant 消息更新后触发,收到浅克隆的完整消息快照。
  • onData 在动态 data 数据块被接受后触发;持久化 data 收到 schema 解析后的数据, transient data 收到原始数据且不写入消息。
  • onToolCall 在动态 tool 消息片段首次到达 input-available 时触发,同一个 toolCallId 只触发一次。它只是通知,不会把返回值写成工具输出,也不会自动续跑。

读取器返回 status、message、terminal、isError、isAbort 和可选 error。 正常完成为 ready;协议、schema、解析器或回调错误为 error;已经接受数据块后的 非协议流中断为 disconnected;外部 abortSignal 取消为 aborted。 readUIMessageStream 是底层读取工具,只负责把 UIMessage 数据块流聚合为 assistant 消息;请求发送、普通文本流、对象流、工具续跑、重连恢复等聊天工作流能力应由 useChat、Chat 或调用方自己的编排层处理。

如果后端只返回普通文本流,可以使用 TextStreamChatTransport。它会把文本增量包装成 assistant 的文本消息片段,但不会提供工具、reasoning、data、来源/文件等 UIMessage 事件。

Completion 与对象流

@halo-dev/ai-foundation-sdk 还提供两个文本流组合式函数:

import { experimental_useObject, jsonSchema, useCompletion } from "@halo-dev/ai-foundation-sdk";

const completion = useCompletion({
    api: "/apis/example.halo.run/v1alpha1/completion/stream",
    body: { temperature: 0.2 },
});

await completion.complete("写一个标题");

const object = experimental_useObject<{ title: string; summary: string }>({
    api: "/apis/example.halo.run/v1alpha1/object/stream",
    schema: jsonSchema({
        type: "object",
        properties: {
            title: { type: "string" },
            summary: { type: "string" },
        },
        required: ["title", "summary"],
    }),
});

await object.submit("总结这篇文章");

useCompletion 会发送 { "prompt": "...", ...body },并读取普通文本流。

experimental_useObject 会发送 { "input": "...", "schema": {...}, "output": {...}, ...body }。 其中 schema 是 JSON Schema,output 默认是:

{
    "type": "object",
    "schema": {
        "type": "object"
    }
}

后端应返回模型生成的 JSON 文本流。前端会在流式读取时尽量解析部分 JSON 快照,并在流结束后 对最终 JSON 做校验;后端仍应依赖结构化输出能力对最终结果负责,不能只把任意文本拼成 JSON 返回。

OpenAPI 与流式前端

OpenAPI 生成客户端适合生成类型、路径、headers 和请求体,但常见 Axios 操作方法会把 响应抽象成 Promise,不适合作为浏览器端 ReadableStream / SSE 的消费入口。推荐模式是:

  1. 使用 OpenAPI 生成的参数构造器构造请求参数。
  2. 使用 fromOpenAPIRequestArgs(...) 转成 @halo-dev/ai-foundation-sdk 的预处理请求。
  3. 仍由 DefaultChatTransport、useCompletion 或 experimental_useObject 负责读取流。
import { DefaultChatTransport, fromOpenAPIRequestArgs, useChat } from "@halo-dev/ai-foundation-sdk";
import { ConsoleApiAifoundationHaloRunV1alpha1ModelApiAxiosParamCreator } from "./api/generated";

const paramCreator = ConsoleApiAifoundationHaloRunV1alpha1ModelApiAxiosParamCreator();

const chat = useChat({
    id: "conversation-1",
    transport: new DefaultChatTransport({
        api: "",
        prepareSendMessagesRequest: async ({ body }) => {
            return fromOpenAPIRequestArgs(args, body);
        },
    }),
});

useCompletion 和 experimental_useObject 可通过 prepareRequest 使用同样的模式:

const completion = useCompletion({
    prepareRequest: async ({ body }) => {
        const args = await paramCreator.testModelCompletionStream("model-name", body);
        return fromOpenAPIRequestArgs(args, body);
    },
});

const object = experimental_useObject({
    schema,
    prepareRequest: async ({ body }) => {
        const args = await paramCreator.testModelObjectStream("model-name", body);
        return fromOpenAPIRequestArgs(args, body);
    },
});

保存消息

推荐在 onFinish 中保存 finish.messages()。它已经包含本轮 assistant 响应:

UIMessageChatResult<ChatMetadata> chat = UIMessageChatHandlers.streamText(options -> options
    .model(model)
    .messages(messages)
    .serializer(chunk -> objectMapper.writeValueAsString(chunk))
    .prepare(context -> repository.permissions(context.getMessages())
        .then(Mono.fromRunnable(() -> context.getRequestBuilder()
            .middleware(RagMiddlewares.rag(retriever)))))
    .metadataSupplier(() -> new ChatMetadata(conversationId))
    .onFinish(finish -> {
        List<UIMessage<ChatMetadata>> updatedMessages = finish.messages();
        UIMessage<ChatMetadata> responseMessage = finish.responseMessage();
        saveMessages(conversationId, updatedMessages, responseMessage);
    }));

如果响应是在继续已有 assistant 消息,finish.isContinuation() 会返回 true。 判断规则是:原始消息最后一条是 assistant,且 id 与响应消息 id 相同,则替换最后一条; 否则追加新的 assistant 消息。

prepare(...) 是异步 hook,执行顺序在 UI 消息校验、转换为模型消息之后,在最终 LanguageModel.streamText(...) 之前。它可以读取 chatRequest、有效 UI messages、 转换结果和 GenerateTextRequestBuilder,适合做权限检查、仓库解析、RAG 配置和 request-scoped middleware 附加。

返回结构化流

如果不是 HTTP SSE 场景,可以直接使用结构化流:

UIMessageChatResult<ChatMetadata> chat = UIMessageChatHandlers.streamText(options -> options
    .model(model)
    .messages(messages));

Flux<UIMessageChunk> chunks = chat.stream().chunks();

如果已有 StreamTextResult,也可以只做协议转换:

StreamTextResult result = model.streamText(request);

UIMessageStreamResponse response = result.toUIMessageStreamResponse(
    chunk -> objectMapper.writeValueAsString(chunk));

自定义数据

调用方可以在模型输出前后写入自己的业务数据,再合并模型流:

UIMessageStream stream = UIMessageStreams.createWithOptions(options -> options
    .onFinish(finish -> saveMessages(conversationId, finish.messages()))
    .execute(writer -> {
        writer.writeTransientData("status", "retrieving");
        writer.writeData("sources", sources);
        writer.merge(result.toUIMessageStream());
    }));
方法 作用
writeData(name, data) 写入会保存到 UIMessage.parts 的数据
writeTransientData(name, data) 写入只给本轮界面使用的数据
writeMessageMetadata(metadata) 更新消息级 metadata
writeText(text) 写入完整文本块
writeFile(fileId, file) 写入会保存为 FilePart 的生成文件
merge(stream) 合并另一个 UIMessageStream

图像生成结果

独立的图像生成模型不是语言模型的 token 流,所以调用 ImageGenerationModel.generateImage(...) 不会自动进入 UIMessageChatHandlers.streamText(...) 的聊天流。如果你的聊天界面需要展示生成图, 后端应在完成图像生成后,把返回的 GeneratedFile 写成 UI Message 的 file 数据块。

return aiModelService()
    .flatMap(service -> service.imageGenerationModel(imageModelName))
    .flatMap(model -> model.generateImage(GenerateImageRequest.builder()
        .prompt("一张 Halo 插件市场封面图")
        .size(1024)
        .build()))
    .map(result -> UIMessageStreams.create("assistant-image-1", writer -> {
        for (int i = 0; i < result.getImages().size(); i++) {
            writer.writeFile("generated-image-" + i, result.getImages().get(i));
        }
    }));

writer.writeFile(fileId, file) 会把 GeneratedFile.getUrl() 或 GeneratedFile.getBase64() 映射到 FilePart。fileId 由调用方提供,并作为聚合时的稳定替换键。 AI Foundation 不会把生成图保存为附件,也不会下载供应方返回的 URL;是否落盘、缓存、转存或清理, 仍由调用方插件负责。

也可以手动写 UIMessageChunks.file(...):

writer.write(UIMessageChunks.file(
    "generated-image-1",
    file.getUrl(),
    file.getTitle(),
    file.getMediaType(),
    file.getBase64(),
    file.getMetadata()));

聚合后的 assistant UIMessage.parts 中会出现 FilePart。前端可以按 url 或 data 展示图片;持久化时保存的是 UI Message 协议数据,不是附件资源本身。

RAG source 默认使用标准 source chunk,最终会持久化为 SourceUrlPart。AI Foundation 约定的 RAG 自定义 data 名称为:

名称 用途
RagUIMessageDataNames.SOURCES 仅发送可展示 source references
RagUIMessageDataNames.RETRIEVED_DATA 调用方显式选择时发送完整检索数据

默认推荐 RagUIMessageOutputMode.SOURCES_ONLY。只有确认前端和存储层可以接收检索全文时, 才使用 SOURCES_WITH_RETRIEVED_DATA。

transientData=true 的 data 数据块不会保存到 UIMessage.parts。

聚合为 UIMessage

需要手动读取数据块并得到最终消息时,使用 UIMessageStreamReader:

ReadUIMessageStreamResult<ChatMetadata> read = UIMessageStreamReader.read(options -> options
    .stream(stream)
    .messageIdGenerator(() -> "msg_" + UUID.randomUUID())
    .metadataSupplier(() -> new ChatMetadata(conversationId)));

Flux<UIMessage<ChatMetadata>> snapshots = read.messages();
Mono<UIMessage<ChatMetadata>> finalMessage = read.responseMessage();
Mono<UIMessageStreamTerminal> terminal = read.finish();
方法 含义
messages() 可见消息状态变化时产生的 assistant 消息快照
responseMessage() 流结束后的最终 assistant 消息
finish() 终态信息:finish reason、usage、abort、错误文本

聚合规则

数据块 聚合结果
text-start / text-delta / text-end 累加为 TextPart
reasoning-start / reasoning-delta / reasoning-end 累加为 ReasoningPart
data 且 transientData=false 写入或替换 DataPart
data 且 transientData=true 不进入 UIMessage.parts
message-metadata 合并到 UIMessage.metadata
source-url / file 写入或替换来源/文件消息片段
tool-input-start / tool-input-delta 私下累加原始 JSON,并把最佳 partial input 聚合到同一个动态 tool-* 片段
tool-input-available 用权威最终输入覆盖 partial input,并进入 input-available 状态
tool-input-error 输入校验或修复失败,进入 output-error;不会触发 onToolCall
tool-output-available / tool-output-error 聚合为同一个动态 tool-* 消息片段的完成状态
tool-approval-request / tool-approval-response 聚合为同一个动态 tool-* 消息片段的审批状态
start-step 追加无字段的 StepStartPart(传输类型为 step-start)
finish-step / error / abort 只更新生命周期或终态信息,不进入 UIMessage.parts

StepStartPart 只记录它在有序 parts 中的位置,不保存每次调用都会重新计数的 stepIndex,也不会单独触发一条可见消息。保存 assistant 消息时应原样保留 parts 的顺序; 后续转换会用这些 marker 恢复同一步中的 reasoning、文本和多个工具调用。

SSE 传输层使用 tool-input-*、tool-output-* 和 tool-approval-* 这类规范工具数据块 表达“流中发生的事件”。聚合后的 assistant UIMessage.parts 使用动态 tool-* 消息片段表达最终 UI 状态。

tool-input-delta 的 wire shape 是 { type, toolCallId, inputTextDelta },不包含 toolName;reducer 必须从此前同一 toolCallId 的 tool-input-start 解析名称。因此 delta 先于 start 是协议错误,而重复 start 会重置该调用的私有累积文本。后端的 tool-input-end 只用于 fullStream() 的块边界,不投影到 UI Message wire。

流式 JSON 解析是 best-effort:input-streaming 期间公开 part 只包含当前可解析的 input 或 undefined,不会暴露累积原文。最终 tool-input-available.input 始终覆盖 partial 值。 如果外层 stream 在输入未完成时 error/abort,part 保持 input-streaming 和最后可解析值, 不会伪造 tool-input-error。

OpenAI-compatible provider 可能产生真实 delta,也可能在某次响应中只给出最终调用;通用 Spring AI model 与 Ollama 为 final-only。调用方必须同时接受“start/delta/available”和仅 “available”两种正常路径,不能依据 provider 名称假设 delta 必然存在。

工具续跑

UIMessage 不使用 TOOL 角色。工具生命周期保存在 assistant UIMessage.parts() 中的 动态 tool-* 消息片段;转换为模型请求时,SDK 会按 StepStartPart 划分 generation step, 每个非空 step 最多生成一条 assistant ModelMessage 和随后一条 tool ModelMessage。

外部工具执行成功后,把包含原始 tool-* 消息片段的 assistant 消息更新为 output-available:

UIMessage<ChatMetadata> updatedAssistant = assistant.withParts(Stream.concat(
    assistant.parts().stream()
        .filter(part -> !(part instanceof ToolPart tool
            && "call_1".equals(tool.toolCallId()))),
    Stream.of(UIMessageParts.tool(
        "call_1",
        "search_docs",
        ToolPartState.OUTPUT_AVAILABLE,
        Map.of("query", "Halo"),
        null,
        Map.of("title", "Halo"),
        null,
        null,
        Map.of()))
).toList());

外部工具失败时把同一个工具消息片段更新为 output-error:

UIMessagePart toolError = UIMessageParts.tool(
    "call_1",
    "search_docs",
    ToolPartState.OUTPUT_ERROR,
    Map.of("query", "Halo"),
    null,
    null,
    "文档服务暂时不可用",
    null,
    Map.of()
);

审批通过时把审批请求对应的工具消息片段更新为 approval-responded,并保留审批结果:

UIMessagePart approved = UIMessageParts.tool(
    "call_1",
    "delete_file",
    ToolPartState.APPROVAL_RESPONDED,
    Map.of("path", "/tmp/example.txt"),
    null,
    null,
    null,
    new ToolApproval("approval_call_1", true, "用户已确认"),
    Map.of()
);

审批拒绝时同样把工具消息片段更新为 approval-responded。拒绝审批不是工具执行异常, 不要把它映射为 output-error:

UIMessagePart denied = UIMessageParts.tool(
    "call_1",
    "delete_file",
    ToolPartState.APPROVAL_RESPONDED,
    Map.of("path", "/tmp/example.txt"),
    null,
    null,
    null,
    new ToolApproval("approval_call_1", false, "用户拒绝删除"),
    Map.of()
);

如果后端明确返回“因审批拒绝而未执行”的工具终态,可以使用 output-denied 表示; output-error 只表示工具执行过程中的错误。

拒绝审批不需要额外创建第二个消息片段;同一个 toolCallId 始终只保留一个工具消息片段。 审批或外部工具处理完成后,把更新后的 UIMessageChatRequest 再次传给 UIMessageChatHandlers.streamText(...) 即可继续生成。

前端 SDK 的 Chat / useChat 可以托管“工具结果写回 + 下一轮请求”的编排,但自动续跑 仍然需要显式配置。onToolCall 在 input-available 的工具片段已经写入消息状态后触发; 它可以返回 Promise,但 SDK 不等待这个 Promise,也不会把返回值自动转换为工具输出。 调用方应自行执行工具,并通过 addToolOutput 或 addToolApprovalResponse 写回结果。 这些方法可以直接在 onToolCall 中调用;如果当前 assistant stream 尚未结束,SDK 会等流进入 ready 后再判断是否自动续跑。

import {
    DefaultChatTransport,
    lastAssistantMessageHasCompletedToolContinuations,
    useChat,
} from "@halo-dev/ai-foundation-sdk";

const chat = useChat({
    id: "conversation-1",
    transport: new DefaultChatTransport({
        api: "/apis/example.halo.run/v1alpha1/chat/stream",
    }),
    sendAutomaticallyWhen: lastAssistantMessageHasCompletedToolContinuations,
    maxAutomaticSteps: 5,
    onAutomaticStepLimitExceeded() {
        console.warn("automatic tool continuation limit reached");
    },
    onToolCall(part) {
        void executeBrowserTool(part)
            .then((output) =>
                chat.addToolOutput({
                    toolCallId: part.toolCallId,
                    toolName: part.toolName,
                    output,
                }),
            )
            .catch((error) =>
                chat.addToolOutput({
                    toolCallId: part.toolCallId,
                    toolName: part.toolName,
                    state: "output-error",
                    errorText: error instanceof Error ? error.message : "工具执行失败",
                }),
            );
    },
});

sendAutomaticallyWhen 返回 true 时,SDK 会提交包含最新工具结果的消息历史;后续如果模型 再次返回新的客户端工具调用,新的工具结果也可以继续触发自动续跑。为了避免循环调用, maxAutomaticSteps 默认限制为 5。达到上限时聊天状态保持 ready,不会自动变为错误; 需要提示用户或记录日志时可以使用 onAutomaticStepLimitExceeded。

消息 Metadata

UIMessage.metadata 是消息级状态,适合保存模型名、业务资源名、保存状态等信息。 它不进入 UIMessage.parts。

默认合并规则:

已有值 更新值 结果
Map Map 浅合并,后来的键覆盖前面的键
任意值 null 保持不变
非 Map 非 Map 用更新值替换

使用 record 或 POJO 时,建议提供自己的 metadataMerger(...):

record ChatMetadata(String conversationId, String status) {
}

ReadUIMessageStreamResult<ChatMetadata> read = UIMessageStreamReader.read(options -> options
    .stream(stream)
    .metadataSupplier(() -> new ChatMetadata(conversationId, "created"))
    .metadataMerger((current, update) -> {
        if (update instanceof ChatMetadata value) {
            return value;
        }
        if (update instanceof Map<?, ?> map && map.containsKey("status")) {
            return new ChatMetadata(current.conversationId(), map.get("status").toString());
        }
        return current;
    }));

usage、finish reason、model id、request metadata 和 response metadata 属于流 终态或服务商诊断信息,不会被自动提升为 UIMessage.metadata。需要把这些字段保存到消息时, 请显式写入 message-metadata,或包一层自己的流。

校验和转换

再次调用模型前,推荐先校验 UIMessage,再转换成 ModelMessage:

List<UIMessage<ChatMetadata>> validMessages =
    UIMessageValidators.validate(messages, options -> options
        .dataValidator("post-draft", (message, part, context) -> {
            if (part.data() == null) {
                return List.of(new UIMessageValidationIssue(
                    message.id(), message.role().name(), part.type(), part.name(),
                    "part.data.invalid", "post draft data is required"));
            }
            return List.of();
        }));

UIMessageConversionResult conversion =
    UIMessageConverters.convertToModelMessages(validMessages, options -> options
        .dataConverter("post-draft", (part, context) ->
            List.of(ModelMessagePart.text("Draft: " + part.data()))));

默认转换策略:

UI 消息片段 默认结果
TextPart 转为模型文本
ReasoningPart 由模型能力决定;支持 reasoning history 时保留,不支持时丢弃并记录警告
StepStartPart 仅划分 generation step,不生成模型内容
ToolPart + input-available 转为 assistant 工具调用内容
ToolPart + approval-requested 转为 assistant 审批请求内容
ToolPart + output-available / output-error 转为工具消息
ToolPart + approval-responded 转为 assistant 审批请求内容和工具审批响应消息
DataPart 跳过并记录警告,除非注册转换器
SourceUrlPart / FilePart 跳过并记录警告

同一个 step 中的 reasoning、文本和多个工具调用会合并到一条 assistant ModelMessage, 对应的多个工具结果会合并到随后一条 tool ModelMessage。下一个 StepStartPart 才会开始新的 assistant/tool 组合;调用方手动构造且不含 marker 的 assistant 消息按一个隐式 step 处理。

通过 UIMessageChatHandlers.streamText(model, request, ...) 调用时,推理默认使用 UIReasoningConversion.AUTO。SDK 会根据 LanguageModel.capabilities() 自动决定是否 回传 reasoning history:支持时保留可见推理文本和 providerMetadata,不支持时丢弃 reasoning 消息片段,避免继续对话失败。

如果直接调用 UIMessageConverters.convertToModelMessages(...),没有模型上下文, AUTO 会按 PRESERVE_PROVIDER_STATE 处理。需要固定行为时,可以显式设置 DROP、PRESERVE_PROVIDER_STATE、INCLUDE_TEXT_AS_CONTEXT 或 STRICT。

严格模式:

List<ModelMessage> modelMessages = UIMessageConverters.toModelMessages(validMessages,
    options -> options
        .unsupportedPartPolicy(UnsupportedUIMessagePartPolicy.FAIL)
        .emptyMessagePolicy(EmptyUIMessagePolicy.FAIL));

重新生成

重新生成不是服务商失败重试。它表示用户要求重新生成某条 assistant 消息:

UIMessageChatRequest<ChatMetadata> request = new UIMessageChatRequest<>(
    "chat-1",
    messages,
    UIMessageChatTrigger.REGENERATE_MESSAGE,
    "assistant-msg-1"
);

要求:

  • messageId 必须存在。
  • messageId 必须指向 assistant 消息。
  • 处理器会在调用模型前移除目标 assistant 消息以及它后面的消息。
  • 服务商失败重试仍通过 request(builder -> builder.maxRetries(...)) 控制。

中断

前端停止通常表现为 HTTP 连接关闭或响应式订阅取消。需要把这个信号传给模型时,使用 UIMessageCancellation:

UIMessageCancellation cancellation = UIMessageCancellations.create();

UIMessageChatResult<ChatMetadata> chat = UIMessageChatHandlers.streamText(model, chatRequest,
    options -> options
        .cancellationToken(cancellation.token())
        .serializer(chunk -> objectMapper.writeValueAsString(chunk))
        .onFinish(finish -> {
            if (finish.terminal().aborted()) {
                saveDraft(chatRequest.id(), finish.responseMessage());
                return;
            }
            saveMessages(chatRequest.id(), finish.messages());
        }));

Flux<String> body = cancellation.cancelWhenSubscriberCancels(chat.response().body());

取消被识别后,流会写出 abort 数据块,而不是 error 数据块。 finish.terminal().aborted() 为 true。是否保存已经生成的部分 assistant 消息由业务决定。

错误处理

场景 行为
输入校验失败 抛出 InvalidUIMessageException,不会调用模型
转换后没有模型消息 抛出 IllegalArgumentException,不会调用模型
模型或写入器抛错 默认写出 error 数据块
读流聚合抛错 默认写入终态错误文本,可用 terminateOnError(true) 改为传播异常
onFinish 抛错 UIMessageChatResult.finish() 失败

可以用 onError(...) 改写返回给前端的错误文本:

UIMessageChatHandlers.streamText(options -> options
    .model(model)
    .messages(messages)
    .onError(error -> "生成失败,请稍后重试"));