diff --git a/docs/.vitepress/themeConfig.ts b/docs/.vitepress/themeConfig.ts index 92c83e0e3..9a57b75f1 100644 --- a/docs/.vitepress/themeConfig.ts +++ b/docs/.vitepress/themeConfig.ts @@ -36,6 +36,7 @@ const sharedSidebarItems = [ { text: 'AI模型交互工具类', link: 'ai-client' }, { text: '消息数据管理', link: 'message' }, { text: '会话数据管理', link: 'conversation' }, + { text: '工具函数', link: 'utils' }, ], }, ] @@ -43,6 +44,7 @@ const sharedSidebarItems = [ const nav = [ { text: '指南', link: '/guide/quick-start', activeMatch: '/guide/' }, { text: '演示', link: '/examples/assistant', activeMatch: '/examples/' }, + { text: '迁移指南', link: '/migration/bubble-migration', activeMatch: '/migration/' }, ] const sidebar = { @@ -56,6 +58,17 @@ const sidebar = { items: [{ text: '综合示例', link: 'assistant' }], }, ], + '/migration/': [ + { + text: '迁移指南', + base: '/migration/', + items: [ + { text: 'Bubble 气泡', link: 'bubble-migration' }, + { text: 'useMessage 迁移', link: 'use-message-migration' }, + { text: 'useConversation 迁移', link: 'use-conversation-migration' }, + ], + }, + ], } export const themeConfig = { nav, sidebar } diff --git a/docs/demos/bubble/aborted.vue b/docs/demos/bubble/aborted.vue deleted file mode 100644 index 1ee217214..000000000 --- a/docs/demos/bubble/aborted.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/docs/demos/bubble/avatar-and-placement.vue b/docs/demos/bubble/avatar-and-placement.vue index 39fa1fd75..12d3f4713 100644 --- a/docs/demos/bubble/avatar-and-placement.vue +++ b/docs/demos/bubble/avatar-and-placement.vue @@ -1,16 +1,12 @@ diff --git a/docs/demos/bubble/basic.vue b/docs/demos/bubble/basic.vue index c291f9ebf..c432db173 100644 --- a/docs/demos/bubble/basic.vue +++ b/docs/demos/bubble/basic.vue @@ -1,7 +1,7 @@ diff --git a/docs/demos/bubble/content-render-mode.vue b/docs/demos/bubble/content-render-mode.vue new file mode 100644 index 000000000..59d9b7895 --- /dev/null +++ b/docs/demos/bubble/content-render-mode.vue @@ -0,0 +1,31 @@ + + + diff --git a/docs/demos/bubble/content-resolver.vue b/docs/demos/bubble/content-resolver.vue new file mode 100644 index 000000000..e90787c88 --- /dev/null +++ b/docs/demos/bubble/content-resolver.vue @@ -0,0 +1,50 @@ + + + diff --git a/docs/demos/bubble/custom-content-field.vue b/docs/demos/bubble/custom-content-field.vue deleted file mode 100644 index 6584606f4..000000000 --- a/docs/demos/bubble/custom-content-field.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - diff --git a/docs/demos/bubble/custom-renderer.vue b/docs/demos/bubble/custom-renderer.vue new file mode 100644 index 000000000..7c93e72fb --- /dev/null +++ b/docs/demos/bubble/custom-renderer.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/docs/demos/bubble/image.vue b/docs/demos/bubble/image.vue new file mode 100644 index 000000000..8d31a663d --- /dev/null +++ b/docs/demos/bubble/image.vue @@ -0,0 +1,53 @@ + + + diff --git a/docs/demos/bubble/list-array-content.vue b/docs/demos/bubble/list-array-content.vue new file mode 100644 index 000000000..429905201 --- /dev/null +++ b/docs/demos/bubble/list-array-content.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/docs/demos/bubble/list-auto-scroll.vue b/docs/demos/bubble/list-auto-scroll.vue new file mode 100644 index 000000000..188e99404 --- /dev/null +++ b/docs/demos/bubble/list-auto-scroll.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/docs/demos/bubble/list-consecutive.vue b/docs/demos/bubble/list-consecutive.vue new file mode 100644 index 000000000..efab1c65d --- /dev/null +++ b/docs/demos/bubble/list-consecutive.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/docs/demos/bubble/list-custom-group.vue b/docs/demos/bubble/list-custom-group.vue new file mode 100644 index 000000000..830aadb3d --- /dev/null +++ b/docs/demos/bubble/list-custom-group.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/docs/demos/bubble/list-hidden.vue b/docs/demos/bubble/list-hidden.vue index aa363d314..b4e005092 100644 --- a/docs/demos/bubble/list-hidden.vue +++ b/docs/demos/bubble/list-hidden.vue @@ -1,5 +1,5 @@ diff --git a/docs/demos/bubble/loading.vue b/docs/demos/bubble/loading.vue index abf2d073e..79ac09973 100644 --- a/docs/demos/bubble/loading.vue +++ b/docs/demos/bubble/loading.vue @@ -1,63 +1,18 @@ diff --git a/docs/demos/bubble/markdown.vue b/docs/demos/bubble/markdown.vue index 22ef3b035..b0522d419 100644 --- a/docs/demos/bubble/markdown.vue +++ b/docs/demos/bubble/markdown.vue @@ -1,25 +1,19 @@ diff --git a/docs/demos/bubble/max-width.vue b/docs/demos/bubble/max-width.vue deleted file mode 100644 index 19c79b4f5..000000000 --- a/docs/demos/bubble/max-width.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/docs/demos/bubble/messages.vue b/docs/demos/bubble/messages.vue deleted file mode 100644 index b69d47065..000000000 --- a/docs/demos/bubble/messages.vue +++ /dev/null @@ -1,153 +0,0 @@ - - - diff --git a/docs/demos/bubble/provider-renderer.vue b/docs/demos/bubble/provider-renderer.vue new file mode 100644 index 000000000..6c6015843 --- /dev/null +++ b/docs/demos/bubble/provider-renderer.vue @@ -0,0 +1,107 @@ + + + diff --git a/docs/demos/bubble/reasoning.vue b/docs/demos/bubble/reasoning.vue new file mode 100644 index 000000000..6961a3b4d --- /dev/null +++ b/docs/demos/bubble/reasoning.vue @@ -0,0 +1,72 @@ + + + diff --git a/docs/demos/bubble/schema-render.vue b/docs/demos/bubble/schema-render.vue index 2ac9ebcaa..964d80882 100644 --- a/docs/demos/bubble/schema-render.vue +++ b/docs/demos/bubble/schema-render.vue @@ -1,34 +1,32 @@ - - diff --git a/docs/demos/bubble/state-change.vue b/docs/demos/bubble/state-change.vue new file mode 100644 index 000000000..b1de18517 --- /dev/null +++ b/docs/demos/bubble/state-change.vue @@ -0,0 +1,47 @@ + + + diff --git a/docs/demos/bubble/streaming.vue b/docs/demos/bubble/streaming.vue index 02731b5d8..f9d1a75da 100644 --- a/docs/demos/bubble/streaming.vue +++ b/docs/demos/bubble/streaming.vue @@ -1,43 +1,24 @@ + + diff --git a/docs/demos/examples/Assistant.vue b/docs/demos/examples/Assistant.vue index b7eeb8e17..42ce80624 100644 --- a/docs/demos/examples/Assistant.vue +++ b/docs/demos/examples/Assistant.vue @@ -13,7 +13,7 @@ :style="containerStyles" > + + diff --git a/docs/demos/tools/message/ErrorHandling.ts b/docs/demos/tools/message/ErrorHandling.ts new file mode 100644 index 000000000..e02050336 --- /dev/null +++ b/docs/demos/tools/message/ErrorHandling.ts @@ -0,0 +1,68 @@ +import type { MessageRequestBody } from '@opentiny/tiny-robot-kit' +import type { UseMessagePlugin } from '@opentiny/tiny-robot-kit' +import { useMessage, sseStreamToGenerator } from '@opentiny/tiny-robot-kit' + +interface ImportMetaEnv { + BASE_URL?: string +} +interface ImportMetaWithEnv extends ImportMeta { + env?: ImportMetaEnv +} +const meta = typeof import.meta !== 'undefined' ? (import.meta as ImportMetaWithEnv) : null +const baseUrl = meta?.env?.BASE_URL || '' +const apiUrl = window.parent?.location.origin || location.origin + baseUrl + +// 插件:根据 error.name 区分处理;ErrorRenderer 时设置 state.error 供自定义渲染 +const errorHandlingPlugin: UseMessagePlugin = { + name: 'errorHandling', + onError({ currentTurn, error }) { + const message = error instanceof Error ? error.message : String(error) + const lastMessage = currentTurn.at(-1)! + if (error instanceof Error && error.name === 'ErrorRenderer') { + if (!lastMessage.state) lastMessage.state = {} + lastMessage.state.error = { message, name: error.name } + } else { + lastMessage.content = `抱歉,出错了:${message}` + } + }, +} + +/** + * useMessage 错误处理:plugins 中 onError 根据 error.name 区分;ErrorRenderer 时设置 state.error 供自定义渲染 + */ +export function useMessageErrorHandling() { + const responseProvider = async (requestBody: MessageRequestBody, abortSignal: AbortSignal) => { + const lastUser = requestBody.messages.filter((m) => m.role === 'user').pop() + const content = (lastUser?.content as string) || '' + if (content.trim().toLowerCase() === 'error') { + await new Promise((r) => setTimeout(r, 300)) + throw new Error('示例:模拟 API 错误') + } + if (content.trim().toLowerCase() === 'error-renderer') { + await new Promise((r) => setTimeout(r, 300)) + const err = new Error('渲染错误示例:此消息通过 state.error 匹配自定义 error 渲染器。') + err.name = 'ErrorRenderer' + throw err + } + const response = await fetch(`${apiUrl}/api/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...requestBody, stream: true }), + signal: abortSignal, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return sseStreamToGenerator(response, { signal: abortSignal }) + } + return useMessage({ + responseProvider: responseProvider as Parameters[0]['responseProvider'], + plugins: [errorHandlingPlugin], + initialMessages: [ + { + content: '发送任意消息可正常回复;输入「error」模拟 API 错误;输入「error-renderer」使用自定义 error 渲染器。', + role: 'assistant', + }, + ], + }) +} diff --git a/docs/demos/tools/message/ErrorHandling.vue b/docs/demos/tools/message/ErrorHandling.vue new file mode 100644 index 000000000..9fc48e95a --- /dev/null +++ b/docs/demos/tools/message/ErrorHandling.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/docs/demos/tools/message/MockStream.ts b/docs/demos/tools/message/MockStream.ts new file mode 100644 index 000000000..71e397c09 --- /dev/null +++ b/docs/demos/tools/message/MockStream.ts @@ -0,0 +1,43 @@ +import type { ChatCompletion, MessageRequestBody } from '@opentiny/tiny-robot-kit' +import { useMessage } from '@opentiny/tiny-robot-kit' + +// 模拟流式:按字符逐个 yield 固定回复内容 +async function* mockStream(_requestBody: MessageRequestBody, abortSignal: AbortSignal): AsyncGenerator { + const reply = '这是一条模拟流式回复,无需真实 API。' + const id = 'mock-' + Date.now() + for (let i = 0; i < reply.length && !abortSignal.aborted; i++) { + await new Promise((r) => setTimeout(r, 30)) + const deltaContent = reply[i] + yield { + id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: 'mock', + system_fingerprint: null, + choices: [ + { + index: 0, + message: undefined, + delta: i === 0 ? { role: 'assistant', content: deltaContent } : { content: deltaContent }, + finish_reason: i === reply.length - 1 ? 'stop' : null, + logprobs: null, + }, + ], + } + } +} + +/** + * useMessage 模拟流式:responseProvider 为 AsyncGenerator,不依赖真实 API + */ +export function useMessageMockStream() { + return useMessage({ + responseProvider: mockStream, + initialMessages: [ + { + content: '本示例使用模拟的 responseProvider,无需真实 API,适合离线开发。', + role: 'assistant', + }, + ], + }) +} diff --git a/docs/demos/tools/message/MockStream.vue b/docs/demos/tools/message/MockStream.vue new file mode 100644 index 000000000..c3074b214 --- /dev/null +++ b/docs/demos/tools/message/MockStream.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/docs/demos/tools/message/NonStreaming.ts b/docs/demos/tools/message/NonStreaming.ts new file mode 100644 index 000000000..5c40a7dab --- /dev/null +++ b/docs/demos/tools/message/NonStreaming.ts @@ -0,0 +1,38 @@ +import type { ChatCompletion } from '@opentiny/tiny-robot-kit' +import { useMessage } from '@opentiny/tiny-robot-kit' + +interface ImportMetaEnv { + BASE_URL?: string +} +interface ImportMetaWithEnv extends ImportMeta { + env?: ImportMetaEnv +} +const meta = typeof import.meta !== 'undefined' ? (import.meta as ImportMetaWithEnv) : null +const baseUrl = meta?.env?.BASE_URL || '' +const apiUrl = window.parent?.location.origin || location.origin + baseUrl + +/** + * useMessage 非流式:responseProvider 返回 Promise,一次性得到完整结果 + */ +export function useMessageNonStreaming() { + return useMessage({ + responseProvider: async (requestBody, abortSignal): Promise => { + const response = await fetch(`${apiUrl}/api/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...requestBody, stream: false }), + signal: abortSignal, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return response.json() + }, + initialMessages: [ + { + content: '本示例使用非流式接口(stream: false),一次性返回完整结果。', + role: 'assistant', + }, + ], + }) +} diff --git a/docs/demos/tools/message/NonStreaming.vue b/docs/demos/tools/message/NonStreaming.vue new file mode 100644 index 000000000..b7c7bdd6b --- /dev/null +++ b/docs/demos/tools/message/NonStreaming.vue @@ -0,0 +1,36 @@ + + + diff --git a/docs/demos/tools/message/OnBeforeRequest.ts b/docs/demos/tools/message/OnBeforeRequest.ts new file mode 100644 index 000000000..b567eef8f --- /dev/null +++ b/docs/demos/tools/message/OnBeforeRequest.ts @@ -0,0 +1,51 @@ +import type { UseMessagePlugin } from '@opentiny/tiny-robot-kit' +import { useMessage, sseStreamToGenerator } from '@opentiny/tiny-robot-kit' + +interface ImportMetaEnv { + BASE_URL?: string +} +interface ImportMetaWithEnv extends ImportMeta { + env?: ImportMetaEnv +} +const meta = typeof import.meta !== 'undefined' ? (import.meta as ImportMetaWithEnv) : null +const baseUrl = meta?.env?.BASE_URL || '' +const apiUrl = window.parent?.location.origin || location.origin + baseUrl + +// 插件:在 onBeforeRequest 中修改 requestBody,注入 system 消息和 temperature +const modifyRequestPlugin: UseMessagePlugin = { + name: 'modifyRequest', + onBeforeRequest({ requestBody }) { + requestBody.messages = [ + { role: 'system', content: '你是一个简洁的助手,请用简短的话回复。' }, + ...requestBody.messages, + ] + ;(requestBody as Record).temperature = 0.7 + }, +} + +/** + * useMessage onBeforeRequest:插件在请求前修改 requestBody(注入 system、追加参数等) + */ +export function useMessageOnBeforeRequest() { + return useMessage({ + responseProvider: async (requestBody, abortSignal) => { + const response = await fetch(`${apiUrl}/api/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...requestBody, stream: true }), + signal: abortSignal, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return sseStreamToGenerator(response, { signal: abortSignal }) + }, + plugins: [modifyRequestPlugin], + initialMessages: [ + { + content: '本示例通过 onBeforeRequest 插件在请求前注入 system 消息和 temperature 参数。', + role: 'assistant', + }, + ], + }) +} diff --git a/docs/demos/tools/message/OnBeforeRequest.vue b/docs/demos/tools/message/OnBeforeRequest.vue new file mode 100644 index 000000000..1e5dfb119 --- /dev/null +++ b/docs/demos/tools/message/OnBeforeRequest.vue @@ -0,0 +1,36 @@ + + + diff --git a/docs/demos/tools/message/RequestState.ts b/docs/demos/tools/message/RequestState.ts new file mode 100644 index 000000000..64d679cb7 --- /dev/null +++ b/docs/demos/tools/message/RequestState.ts @@ -0,0 +1,39 @@ +import { useMessage, sseStreamToGenerator } from '@opentiny/tiny-robot-kit' + +interface ImportMetaEnv { + BASE_URL?: string +} +interface ImportMetaWithEnv extends ImportMeta { + env?: ImportMetaEnv +} +const meta = typeof import.meta !== 'undefined' ? (import.meta as ImportMetaWithEnv) : null +const baseUrl = meta?.env?.BASE_URL || '' +const apiUrl = window.parent?.location.origin || location.origin + baseUrl + +/** + * useMessage 请求状态:responseProvider 加延迟,便于观察 processingState 从 requesting 变为 completing + */ +export function useMessageRequestState() { + return useMessage({ + responseProvider: async (requestBody, abortSignal) => { + // 延迟 1.5s 再发起请求,便于观察 processingState 从 requesting 变为 completing + await new Promise((resolve) => setTimeout(resolve, 1500)) + const response = await fetch(`${apiUrl}/api/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...requestBody, stream: true }), + signal: abortSignal, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return sseStreamToGenerator(response, { signal: abortSignal }) + }, + initialMessages: [ + { + content: '发送消息后观察状态条:先为 requesting,收到首包后变为 completing,结束后为 completed。', + role: 'assistant', + }, + ], + }) +} diff --git a/docs/demos/tools/message/RequestState.vue b/docs/demos/tools/message/RequestState.vue new file mode 100644 index 000000000..c110db746 --- /dev/null +++ b/docs/demos/tools/message/RequestState.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/docs/demos/tools/message/ToolCall.ts b/docs/demos/tools/message/ToolCall.ts new file mode 100644 index 000000000..bccbfafab --- /dev/null +++ b/docs/demos/tools/message/ToolCall.ts @@ -0,0 +1,111 @@ +import type { ChatCompletion, MessageRequestBody, Tool } from '@opentiny/tiny-robot-kit' +import { toolPlugin, useMessage } from '@opentiny/tiny-robot-kit' + +// 模拟流式:若最后一条是 user,则返回带 tool_calls 的 assistant 消息;否则返回最终文本。 +async function* mockStreamWithTools( + requestBody: MessageRequestBody, + abortSignal: AbortSignal, +): AsyncGenerator { + const msgs = requestBody.messages || [] + const last = msgs[msgs.length - 1] + const id = 'mock-tool-' + Date.now() + + if (last?.role === 'tool') { + // 第二轮:返回最终回答(无 tool_calls) + const text = '根据天气结果,总结如下:晴,25°C。' + for (let i = 0; i < text.length && !abortSignal.aborted; i++) { + await new Promise((r) => setTimeout(r, 60)) + const content = text[i] + yield { + id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: 'mock', + system_fingerprint: null, + choices: [ + { + index: 0, + message: undefined, + delta: i === 0 ? { role: 'assistant', content } : { content }, + finish_reason: i === text.length - 1 ? 'stop' : null, + logprobs: null, + }, + ], + } + } + return + } + + // 第一轮:返回 tool_calls(get_weather) + await new Promise((r) => setTimeout(r, 400)) + yield { + id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: 'mock', + system_fingerprint: null, + choices: [ + { + index: 0, + message: undefined, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: 'call_mock_weather_1', + type: 'function', + function: { + name: 'get_weather', + arguments: '{"city":"Beijing"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + } +} + +const getTools = async (): Promise => [ + { + type: 'function', + function: { + name: 'get_weather', + description: '根据城市名称查询天气。', + parameters: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + }, + }, + }, +] + +/** + * useMessage 工具调用:toolPlugin 的 getTools + callTool,responseProvider 模拟 tool_calls + */ +export function useMessageToolCall() { + return useMessage({ + responseProvider: mockStreamWithTools, + plugins: [ + toolPlugin({ + getTools, + callTool: async (toolCall) => { + const args = JSON.parse(toolCall.function?.arguments || '{}') + return `${args.city} 天气:晴,25°C。` + }, + toolCallCancelledContent: '工具调用已取消。', + toolCallFailedContent: '工具调用失败。', + }), + ], + initialMessages: [ + { + content: '可询问天气(如「北京天气怎么样?」),示例会模拟一次工具调用。', + role: 'assistant', + }, + ], + }) +} diff --git a/docs/demos/tools/message/ToolCall.vue b/docs/demos/tools/message/ToolCall.vue new file mode 100644 index 000000000..2d7841916 --- /dev/null +++ b/docs/demos/tools/message/ToolCall.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/docs/demos/tools/storage/Custom.vue b/docs/demos/tools/storage/Custom.vue new file mode 100644 index 000000000..4cae53da3 --- /dev/null +++ b/docs/demos/tools/storage/Custom.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/docs/demos/tools/storage/IndexedDB.vue b/docs/demos/tools/storage/IndexedDB.vue new file mode 100644 index 000000000..dc3ae1ca1 --- /dev/null +++ b/docs/demos/tools/storage/IndexedDB.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/docs/demos/tools/storage/LocalStorage.vue b/docs/demos/tools/storage/LocalStorage.vue new file mode 100644 index 000000000..9fb46f940 --- /dev/null +++ b/docs/demos/tools/storage/LocalStorage.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/docs/src/components/bubble.md b/docs/src/components/bubble.md index fdb8f660d..decaf087c 100644 --- a/docs/src/components/bubble.md +++ b/docs/src/components/bubble.md @@ -4,202 +4,464 @@ outline: [1, 3] # Bubble 气泡组件 -Bubble 气泡组件用于展示消息气泡,支持流式文本、头像、位置、加载中、终止状态、操作按钮等功能。 +:::danger 重大版本升级 v0.4 +Bubble 在 v0.4 进行了重大升级。 + +**从 v0.3.x 升级?** 请查看 [Bubble 迁移指南](../migration/bubble-migration)。 + +**新项目:** 直接使用下方 v0.4 的 API 和示例即可。 +::: + +Bubble 气泡组件用于展示消息气泡,支持流式文本、头像、位置、加载中、终止状态、操作按钮等功能。组件采用渲染器架构,支持灵活的内容渲染和自定义扩展。 + +主要解决以下问题: + +- **消息展示**:支持文本、图片、Markdown 等多种内容类型的渲染 +- **流式输出**:支持流式文本展示,适用于 AI 对话场景 +- **消息分组**:支持将连续相同角色的消息合并显示 +- **自定义渲染**:通过渲染器系统支持自定义内容渲染逻辑 +- **状态管理**:支持消息状态管理,用于存储 UI 相关的数据 ## 代码示例 ### 基本示例 -基本示例。使用 `content` 属性设置气泡内容,使用 css 变量 `--tr-bubble-content-bg` 设置气泡内容背景颜色。 +基本示例。使用 `content` 属性设置气泡内容,可以使用 css 变量来设置样式,比如: + +- 气泡背景 `--tr-bubble-box-bg` +- 气泡文字大小 `--tr-bubble-text-font-size` > 更多 css 变量请参考 [CSS 变量](#css-变量) + + ### 头像和位置 通过 `avatar` 设置自定义头像,通过 `placement` 设置位置,提供了 `start`、`end` 两个选项 + + ### 气泡形状 -通过 `shape` 设置气泡形状。目前提供了 `rounded` 和 `corner` 两个选项。默认为 `corner` +通过 `shape` 设置气泡形状。目前提供了 `rounded`、`corner` 和 `none` 三个选项。默认为 `corner`,可以使用 css 变量来设置圆角 -### 加载中 +- rounded 形状气泡圆角 `--tr-bubble-box-shape-rounded-radius` +- corner 形状气泡圆角 `--tr-bubble-box-shape-corner-radius`。这个 CSS 变量只会设置 corner 一个角的圆角,另外3个角则使用的 `--tr-bubble-box-shape-rounded-radius` 的值 +- none 形状气泡圆角 `--tr-bubble-box-border-radius` + + -通过 `loading` 设置加载中状态。或者使用 `loading` 插槽来实现自定义加载中状态 +### 加载中 -BubbleList 除了需要设置 `loading`,还需要设置 `loading-role`。需要注意的是,列表的加载中气泡实际上并没有新增一条消息,`loading` 设置为 `false` 后,加载中的气泡不会渲染 +通过 `loading` 设置加载中状态 -### 用户停止 + -通过 `aborted` 设置用户停止状态 +### 渲染 markdown -### 最大宽度 +Bubble 组件提供了 `markdown` 渲染器,可以渲染 markdown 内容。需要安装 `markdown-it` 和 `dompurify` 依赖 -通过 `maxWidth` 设置气泡最大宽度 +```bash +# npm +npm install markdown-it dompurify +# yarn +yarn add markdown-it dompurify +# pnpm +pnpm add markdown-it dompurify +``` -### 渲染 markdown + ### 流式文本 `content` 属性是响应式的,动态设置 `content` 即可实现流式文本 -### 多种消息格式 + -`BubbleProvider` 管理和注册消息渲染器。渲染器注册机制 +### 图片渲染 -当 Bubble 组件的 `content` 是长度大于0的数组时,系统会: +Bubble 组件支持渲染图片内容。当 `content` 为数组且包含 `type: 'image_url'` 的内容项时,会自动使用 Image 渲染器。 -1.检查每数组项的 `type` 字段 -2.在 `BubbleProvider` 中查找匹配的渲染器 -3.使用找到的渲染器渲染消息内容 -4.如果未找到匹配的渲染器,则使用默认渲染方式 +图文混合时,可以通过 `contentRenderMode` 控制渲染方式: -有三种方式可以实现自定义消息渲染器: +- `'single'` 模式:文本和图片在同一个 box 中渲染 +- `'split'` 模式:每个内容项(文本或图片)单独一个 box -1.**函数式渲染器**: + -```typescript -const myRenderer: BubbleContentFunctionRenderer = (options) => { - return h('div', options.content) -} +### 内容渲染模式 + +通过 `contentRenderMode` 设置内容渲染模式: + +- `'single'`(默认):所有内容在一个 box 中渲染 +- `'split'`:当 `content` 为数组时,每个内容项单独一个 box + + + +> **注意**:`'single'` 模式会将所有内容在一个 box 中渲染(默认)。`'split'` 模式会在 `content` 为数组时,将每个内容项单独一个 box 渲染。 + +### 内容解析器 + +通过 `contentResolver` 可以自定义内容解析逻辑,用于从消息的其他字段提取内容。 + + + +> **注意**:默认情况下,组件使用 `message.content` 作为内容。如果需要自定义内容解析逻辑(例如从其他字段提取内容),可以通过 `contentResolver` 属性传入自定义函数。 + +### 插槽 + +气泡组件提供了多个插槽,分别是 `prefix` 插槽, `suffix` 插槽、`content-footer` 插槽 和 `after` 插槽 + + + +### schema 卡片渲染 + + + +### 列表 + + + +### 分组策略 + +BubbleList 支持多种分组策略: + +**连续分组(consecutive)** + +连续相同角色的消息会被合并为一组。 + + + +**自定义分组函数** + +可以通过自定义函数实现更灵活的分组逻辑。 + + + +**数组内容的分组** + +当 `message.role === 'user'` 且 `content` 为数组时,该消息会被单独作为一个独立分组(密封),后续的消息(即使角色相同)也不会被添加到这个分组中。 + + + +> **注意**:分组策略的特殊处理规则: +> +> - 当 `message.role === 'user'` 且 `content` 为数组时,该消息会被单独作为一个独立分组(密封),后续的消息(即使角色相同)也不会被添加到这个分组中 +> - `hidden` 消息的分组规则:连续的 `hidden` 消息可以同一组 + +### 隐藏角色 + +角色配置中使用 `hidden` 来隐藏这个角色的所有消息 + + + +### 自动滚动 + +通过 `autoScroll` 属性启用自动滚动功能。当新消息添加时,如果滚动容器接近底部,会自动滚动到底部。 + + + +> **注意**:`autoScroll` 功能有两种触发机制: +> +> 1. **常规自动滚动**:当消息内容变化时(如消息数量、内容、推理内容),如果满足以下条件会自动滚动: +> - BubbleList 必须是可滚动容器(`scrollHeight > clientHeight`) +> - 滚动容器需要接近底部 +> 2. **用户消息特殊处理**:当最后一条消息的 `role` 为 `'user'` 时,会立即使用平滑滚动(`smooth`)滚动到底部,无需满足上述条件。这确保了用户发送消息后能立即看到自己发送的内容。 + +### 自定义渲染器 + +Bubble 组件采用渲染器架构,支持灵活的内容渲染和自定义扩展。渲染器系统分为两种类型: + +- **Box 渲染器**:用于渲染消息的外层容器(box),控制气泡的样式和布局 +- **Content 渲染器**:用于渲染消息的具体内容,如文本、图片、Markdown 等 + +#### 渲染器匹配机制 + +渲染器通过匹配规则来选择,匹配过程如下: + +1. 按照优先级排序所有匹配规则(`priority` 值越小优先级越高) +2. 依次执行每个规则的 `find` 函数,找到第一个返回 `true` 的规则 +3. 使用该规则对应的渲染器 +4. 如果没有匹配到任何规则,使用 fallback 渲染器 + +#### 渲染器配置层级 + +渲染器配置支持三个层级,优先级从高到低: + +1. **Prop 级别**:通过 `Bubble`、`BubbleList` 的 `fallback-box-renderer` 和 `fallback-content-renderer` 属性配置,只对当前组件生效 +2. **Provider 级别**:通过 `BubbleProvider` 的 `box-renderer-matches`、`content-renderer-matches` 和 fallback 属性配置,在整个组件树中生效 +3. **Default 级别**:内置的默认渲染器和匹配规则 + +**设置 Fallback 渲染器** + +当无法匹配到合适的渲染器时,会使用 fallback 渲染器。上面的[渲染 markdown 示例](#渲染-markdown)中,就是通过 `fallback-content-renderer` 属性设置的 `BubbleRenderers.Markdown` 渲染器。 + +```vue + ``` -2.**类式渲染器**: +#### 通过 BubbleProvider 配置渲染器 -必须继承 `BubbleContentClassRenderer` 类 +`BubbleProvider` 组件提供了 `box-renderer-matches` 和 `content-renderer-matches` 属性,用于设置渲染器匹配规则。通过 BubbleProvider 配置的渲染器会在整个组件树中生效,适合全局配置。 -类渲染器通常用来复用复杂度较高的渲染器,比如MarkdownIt实例 + -```typescript -class MyRenderer extends BubbleContentClassRenderer { - render(options) { - return h('div', options.content) - } -} +#### 渲染器匹配优先级 + +匹配规则可以使用 `priority` 属性来设置优先级,值越小优先级越高。系统提供了以下优先级常量: + +- `BubbleRendererMatchPriority.LOADING`: -1 + + 通常基于 `message.loading` 判断,用于加载状态渲染器。例如:`{ loading: true }` + +- `BubbleRendererMatchPriority.NORMAL`: 0 + + 普通渲染器的默认优先级。未设置优先级时,默认使用该优先级 + +- `BubbleRendererMatchPriority.CONTENT`: 10 + + 通常基于 `message.content` 判断。例如:`{ content: [{ type: 'image_url', image_url: 'xxx' }] }` + +- `BubbleRendererMatchPriority.ROLE`: 20 + + 通常基于 `message.role` 判断。例如:`{ role: 'tool' }` + +> **注意**:渲染器匹配时,优先级数值越小优先级越高。自定义渲染器应该根据匹配条件选择合适的优先级。 + +#### 内置渲染器 + +组件内置了以下渲染器,可以通过 `BubbleRenderers` 访问: + +- `BubbleRenderers.Box` - 默认 Box 渲染器 +- `BubbleRenderers.Text` - 文本内容渲染器(默认 Content 渲染器) +- `BubbleRenderers.Image` - 图片渲染器 +- `BubbleRenderers.Markdown` - Markdown 渲染器 +- `BubbleRenderers.Loading` - 加载状态渲染器 +- `BubbleRenderers.Reasoning` - 推理内容渲染器 +- `BubbleRenderers.Tool` - 单个工具调用渲染器 +- `BubbleRenderers.Tools` - 工具调用列表渲染器 +- `BubbleRenderers.ToolRole` - 工具角色消息渲染器 + + + + + +#### 实现自定义渲染器 + +**Content 渲染器示例** + +Content 渲染器接收 `BubbleContentRendererProps` 作为 props,包含 `message` 和可选的 `contentIndex`。 + +```vue + ``` -注册时记得 new 一个实例,否则会导致渲染失败 +或者使用 `.vue` 文件: ```vue + - ``` -3.**Vue 组件**: +**Box 渲染器示例** -content 对象中的所有属性都将传递给组件,onXXX会当作事件传递给组件,非props属性会当作attrs传递给组件 +Box 渲染器接收 `BubbleBoxRendererProps` 作为 props,包含 `placement` 和 `shape`,并通过插槽渲染内容。 ```vue + + ``` -目前内置直接可用的的渲染器类型有 +**配置自定义渲染器** -- `text`(默认渲染器) -- `collapsible-text` -- `tool` +配置自定义渲染器有两种方式: -内置需要自行导入的渲染有 +**方式一:通过 BubbleProvider 配置匹配规则**(推荐用于全局配置) -- `BubbleMarkdownContentRenderer` 类渲染器 + -### 指定渲染属性 +**方式二:通过 fallback 属性配置**(用于单个组件) -和大模型交互数据时,交互的原始数据中的 content 字段可能需要经过前端二次处理再展示到UI上,但此时我们又不想改动原始的 content 字段。此时可以通过 `customContentField` 属性来在前端指定你需要渲染的属性 + -### 插槽 +**注意事项** -气泡组件提供了四个插槽,分别是 默认插槽, `loading` 插槽、`footer` 插槽 和 `trailer` 插槽 +- 使用 `markRaw` 包装渲染器组件,避免 Vue 的响应式处理 +- 为了不修改源数据内部内容和结构,UI 相关的数据应放在消息的 `state` 属性中 +- Box 渲染器的 `find` 函数签名:`(messages, content, contentIndex) => boolean`,其中 `content` 仅在 split 模式有值 +- Content 渲染器的 `find` 函数签名:`(message, content, contentIndex) => boolean`,`content` 为统一化后的 `ChatMessageContentItem` +- 在 Content 渲染器中可使用 `useMessageContent(props)` 获取当前 `content` 和 `contentText`,以正确处理 `contentIndex` 与数组内容 -### 列表 +```vue + -### 隐藏角色 + +``` -## Props +### 状态管理 -**BubbleCommonProps** - 气泡通用属性配置 +Bubble 组件支持通过 `state` 属性存储 UI 相关的数据,并通过 `state-change` 事件来更新状态。这对于实现交互功能(如展开/收起、点赞等)非常有用。 -| 属性 | 类型 | 默认值 | 说明 | -| -------------------- | ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------- | -| `placement` | `BubblePlacement` | - | 气泡对齐位置 (`'start'` 或 `'end'`) | -| `avatar` | `VNode` | - | 气泡头像部分的自定义 Vue 节点 | -| `shape` | `'rounded' \| 'corner'` | `'corner'` | 气泡形状 | -| `contentRenderer` | `BubbleContentRenderer` | - | 气泡内容渲染器(当 content 是非空数组时无效,使用 BubbleProvider 注册的渲染器) | -| `customContentField` | `string` | - | 自定义气泡内容字段。比如 customContentField 设置为 'my-content',则 Bubble 优先渲染 my-content 属性到气泡内容 | -| `abortedText` | `string` | `'(用户停止)'` | 气泡中止文本 | -| `maxWidth` | `string \| number` | - | 气泡内容的最大宽度 | + -**BubbleProps** - 单个气泡的属性配置(继承自 BubbleCommonProps) +> **注意**:消息的 `state` 属性用于存储 UI 相关的数据,不会影响消息内容。可以通过 `state-change` 事件来更新状态。 -| 属性 | 类型 | 默认值 | 说明 | -| --------- | ------------------------------- | ------- | ----------------------------------- | -| `content` | `string \| BubbleContentItem[]` | - | 气泡内容 | -| `id` | `string \| number \| symbol` | - | 气泡唯一标识 | -| `role` | `string` | - | 气泡角色标识,用于关联 `roles` 配置 | -| `loading` | `boolean` | `false` | 是否显示加载状态 | -| `aborted` | `boolean` | `false` | 是否显示为已中止状态 | +## Props + +**BubbleProps** - 单个气泡的属性配置 + +| 属性 | 类型 | 默认值 | 说明 | +| ------------------------- | ------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------- | +| `role` | `string` | - | 气泡角色标识,用于关联 `roleConfigs` 配置 | +| `content` | `string \| ChatMessageContentItem[]` | - | 气泡内容 | +| `reasoning_content` | `string` | - | 推理内容(用于 Reasoning 渲染器) | +| `tool_calls` | `ToolCall[]` | - | 工具调用列表(用于 Tool 渲染器) | +| `tool_call_id` | `string` | - | 工具调用 ID | +| `name` | `string` | - | 消息名称 | +| `id` | `string` | - | 气泡唯一标识 | +| `loading` | `boolean` | `false` | 是否显示加载状态 | +| `state` | `Record` | - | 消息状态数据(用于存储 UI 相关的数据,不会影响消息内容) | +| `hidden` | `boolean` | `false` | 是否隐藏气泡 | +| `avatar` | `VNode \| Component` | - | 气泡头像部分的自定义 Vue 节点或组件 | +| `placement` | `'start' \| 'end'` | `'start'` | 气泡对齐位置 | +| `shape` | `'corner' \| 'rounded' \| 'none'` | `'corner'` | 气泡形状 | +| `contentRenderMode` | `'single' \| 'split'` | `'single'` | 内容渲染模式。`'single'` 表示所有内容在一个 box 中,`'split'` 表示每个内容项单独一个 box | +| `contentResolver` | `(message: BubbleMessage) => ChatMessageContent \| undefined` | `(message) => message.content` | 内容解析函数,用于解析消息内容 | +| `fallbackBoxRenderer` | `Component` | - | 默认 box 渲染器(当无法匹配到合适的渲染器时使用) | +| `fallbackContentRenderer` | `Component` | - | 默认内容渲染器(当无法匹配到合适的渲染器时使用) | **BubbleListProps** - 气泡列表组件的属性配置 -| 属性 | 类型 | 默认值 | 说明 | -| ------------- | ------------------------------------------- | ------- | ---------------------------- | -| `items` | `(BubbleProps & { slots?: BubbleSlots })[]` | - | **必填**,气泡项数组 | -| `roles` | `Record` | - | 每个角色的默认配置项 | -| `loading` | `boolean` | `false` | 列表是否加载中 | -| `loadingRole` | `string` | - | 指定哪个角色可以有加载中状态 | -| `autoScroll` | `boolean` | `false` | 是否自动滚动到最新内容 | +| 属性 | 类型 | 默认值 | 说明 | +| ------------------- | ------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `messages` | `BubbleMessage[]` | - | **必填**,消息数组 | +| `groupStrategy` | `'consecutive' \| 'divider' \| BubbleGroupFunction` | `'divider'` | 分组策略:
- `'consecutive'`: 连续相同角色的消息合并为一组
- `'divider'`: 按分割角色分组(连续的分割角色在一组,其他消息在另一组)
- 自定义函数: `(messages, dividerRole?) => BubbleMessageGroup[]` | +| `dividerRole` | `string` | `'user'` | `'divider'` 策略的分割角色,具有此角色的消息将作为分割线 | +| `fallbackRole` | `string` | `'assistant'` | 当消息没有角色或角色为空时,使用此角色 | +| `roleConfigs` | `Record` | - | 每个角色的默认配置项(头像、位置、形状等) | +| `contentRenderMode` | `'single' \| 'split'` | - | 内容渲染模式 | +| `contentResolver` | `(message: BubbleMessage) => ChatMessageContent \| undefined` | `(message) => message.content` | 内容解析函数,用于解析消息内容 | +| `autoScroll` | `boolean` | `false` | 是否自动滚动到底部。需要满足以下条件:
- BubbleList 是可滚动容器(需要 scrollHeight > clientHeight)
- 滚动容器接近底部 | -**BubbleProviderProps** +**BubbleList Expose** -```ts -type BubbleProviderProps = { - contentRenderers?: Record -} -``` +| 方法 | 签名 | 说明 | +| ---------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------- | +| `scrollToBottom` | `(behavior?: ScrollBehavior) => Promise` | 滚动到底部。传入 `'smooth'` 可平滑滚动。若未启用 `autoScroll`,调用后无实际滚动效果。 | + +**BubbleProviderProps** - 气泡提供者组件的属性配置 + +| 属性 | 类型 | 默认值 | 说明 | +| ------------------------- | --------------------------------------- | ------ | ---------------------------------------------------------- | +| `boxRendererMatches` | `BubbleBoxRendererMatch[]` | - | Box 渲染器匹配规则数组 | +| `contentRendererMatches` | `BubbleContentRendererMatch[]` | - | 内容渲染器匹配规则数组 | +| `fallbackBoxRenderer` | `Component` | - | 默认 box 渲染器(当无法匹配到合适的渲染器时使用) | +| `fallbackContentRenderer` | `Component` | - | 默认内容渲染器(当无法匹配到合适的渲染器时使用) | +| `store` | `Record` | - | 全局状态存储,用于在 BubbleList 和 Bubble 组件之间共享数据 | + +## Emits + +**Bubble 和 BubbleList 组件的事件** + +| 事件名 | 参数类型 | 说明 | +| -------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `state-change` | `{ key: string; value: unknown; messageIndex: number; contentIndex: number }` | 当消息状态改变时触发。`key` 为状态键名,`value` 为状态值,`messageIndex` 为消息索引,`contentIndex` 为内容索引 | ## Slots -| 插槽名 | 参数 | 说明 | -| --------- | ---------------------------------------------- | ------------------------------------ | -| `default` | `{ bubbleProps: BubbleProps; index?: number }` | 默认内容插槽,用于自定义气泡内容 | -| `footer` | `{ bubbleProps: BubbleProps; index?: number }` | 底部插槽,用于在气泡底部添加内容 | -| `loading` | `{ bubbleProps: BubbleProps; index?: number }` | 加载状态插槽,用于自定义加载状态显示 | -| `trailer` | `{ bubbleProps: BubbleProps; index?: number }` | 尾部插槽,用于在气泡内容外部添加内容 | +**Bubble 组件插槽** + +| 插槽名 | 参数 | 说明 | +| ---------------- | --------------------------------------------------------------------- | ---------------------------------------- | +| `prefix` | `{ messages: BubbleMessage[]; role?: string }` | 前缀插槽,用于在气泡前添加内容 | +| `suffix` | `{ messages: BubbleMessage[]; role?: string }` | 后缀插槽,用于在气泡后添加内容 | +| `after` | `{ messages: BubbleMessage[]; role?: string }` | 尾部插槽,用于在气泡内容外部添加内容 | +| `content-footer` | `{ messages: BubbleMessage[]; role?: string; contentIndex?: number }` | 内容底部插槽,用于在气泡内容底部添加内容 | + +**BubbleList 组件插槽** + +| 插槽名 | 参数 | 说明 | +| ---------------- | ----------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `prefix` | `{ messages: BubbleMessage[]; role?: string; messageIndexes: number[] }` | 前缀插槽,用于在气泡前添加内容 | +| `suffix` | `{ messages: BubbleMessage[]; role?: string; messageIndexes: number[] }` | 后缀插槽,用于在气泡后添加内容 | +| `after` | `{ messages: BubbleMessage[]; role?: string; messageIndexes: number[] }` | 尾部插槽,用于在气泡内容外部添加内容 | +| `content-footer` | `{ messages: BubbleMessage[]; role?: string; contentIndex?: number; messageIndexes: number[] }` | 内容底部插槽,用于在气泡内容底部添加内容 | ## Types -**BubblePlacement** - 气泡位置类型 +**BubbleMessage** - 消息基础类型 ```typescript -type BubblePlacement = 'start' | 'end' +interface BubbleMessage< + T extends ChatMessageContent = ChatMessageContent, + S extends Record = Record, +> { + role?: string + content?: T + reasoning_content?: string + tool_calls?: ToolCall[] + tool_call_id?: string + name?: string + id?: string + loading?: boolean + state?: S +} ``` -- `'start'`: 气泡位于左侧/起始位置 -- `'end'`: 气泡位于右侧/结束位置 - -**BubbleRoleConfig** - 角色配置类型(继承自 BubbleCommonProps) +**ChatMessageContent** - 消息内容类型 -```ts -type BubbleRoleConfig = BubbleCommonProps & { - hidden?: boolean - slots?: BubbleSlots -} +```typescript +type ChatMessageContent = string | ChatMessageContentItem[] ``` -**BubbleContentItem** - 单条消息对象的结构 +**ChatMessageContentItem** - 单条消息内容项的结构 ```typescript -interface BubbleContentItem { +interface ChatMessageContentItem { type: string [key: string]: any } @@ -210,32 +472,93 @@ interface BubbleContentItem { | `type` | `string` | 消息类型,用于选择对应的渲染器 | | `[key: string]` | `any` | 其他字段可自由扩展,用于携带消息所需的自定义数据 | -**BubbleContentRenderer** - 用于渲染气泡消息内容的渲染器类型 +**ToolCall** - 工具调用接口 + +```typescript +interface ToolCall { + id: string + type: 'function' | string + function: { + name: string + arguments: string + } + [x: string]: any +} +``` + +**BubbleRoleConfig** - 角色配置类型 + +```typescript +type BubbleRoleConfig = Pick< + BubbleProps, + 'avatar' | 'placement' | 'shape' | 'hidden' | 'fallbackBoxRenderer' | 'fallbackContentRenderer' +> +``` + +**BubbleBoxRendererMatch** - Box 渲染器匹配规则 + +```typescript +type BubbleBoxRendererMatch = { + find: ( + messages: BubbleMessage[], + content: ChatMessageContentItem | undefined, + contentIndex: number | undefined, + ) => boolean + renderer: Component + priority?: number + attributes?: Record +} +``` + +- `content`: 仅在 `split` 模式(`contentIndex` 为数字)时传入,为当前消息经 `contentResolver` 解析后对应索引的内容项;`contentIndex` 为 `undefined` 时 `content` 也为 `undefined` +- `contentIndex`: 仅在 split 模式下传入,此时 `messages` 长度为 1 + +**BubbleContentRendererMatch** - 内容渲染器匹配规则 ```typescript -type BubbleContentRenderer = BubbleContentFunctionRenderer | BubbleContentClassRenderer | Component +type BubbleContentRendererMatch = { + find: (message: BubbleMessage, content: ChatMessageContentItem, contentIndex: number) => boolean + renderer: Component + priority?: number + attributes?: Record +} ``` -- `BubbleContentFunctionRenderer`: 函数式渲染器,返回 `VNode` -- `BubbleContentClassRenderer`: 基于类的渲染器,需实现 `.render()` 方法 -- `Component`: 任意 Vue 组件,也可以用作渲染器 +- `content`: 当前消息经 `contentResolver` 解析并统一化后的内容项;若为数组则取 `contentIndex` 对应项,若为字符串则转为 `{ type: 'text', text: string }` +- `contentIndex`: 内容索引,字符串解析时为 0 -**BubbleContentFunctionRenderer** - 函数式消息渲染器 +**BubbleBoxRendererProps** - Box 渲染器属性 ```typescript -type BubbleContentFunctionRenderer = (options: { [key: string]: any }) => VNode +type BubbleBoxRendererProps = Pick ``` -| 参数 | 类型 | 说明 | -| --------- | ------------------------ | ------------------------------------------- | -| `options` | `{ [key: string]: any }` | 与消息类型 (`BubbleContentItem`) 对应的数据 | -| 返回值 | `VNode` | 渲染结果 | +**BubbleContentRendererProps** - 内容渲染器属性 -**BubbleContentClassRenderer** - 基于类的消息渲染器 +```typescript +type BubbleContentRendererProps< + T extends ChatMessageContent = ChatMessageContent, + S extends Record = Record, +> = { + message: BubbleMessage + contentIndex: number +} +``` + +**BubbleGroupFunction** - 自定义分组函数类型 ```typescript -abstract class BubbleContentClassRenderer { - abstract render(options: { [key: string]: any }): VNode +type BubbleGroupFunction = (messages: BubbleMessage[], dividerRole?: string) => BubbleMessageGroup[] +``` + +**BubbleMessageGroup** - 消息分组类型 + +```typescript +type BubbleMessageGroup = { + role: string + messages: BubbleMessage[] + messageIndexes: number[] + startIndex: number } ``` @@ -247,25 +570,23 @@ abstract class BubbleContentClassRenderer { | ----------------------- | -------------- | | `--tr-bubble-gap` | 头像与内容间距 | | `--tr-bubble-max-width` | 气泡最大宽度 | +| `--tr-bubble-min-width` | 气泡最小宽度 | -**avatar 头像** - -| 变量名 | 说明 | -| ------------------------- | -------- | -| `--tr-bubble-avatar-size` | 头像尺寸 | - -**content 内容** +**box 容器** -| 变量名 | 说明 | -| ----------------------------------- | --------------------------------------------------- | -| `--tr-bubble-content-bg` | 内容背景色 | -| `--tr-bubble-content-border-radius` | 内容圆角大小 | -| `--tr-bubble-content-box-shadow` | 内容阴影效果 | -| `--tr-bubble-content-padding` | 内容内边距 | -| `--tr-bubble-content-border` | 内容边框样式 | -| `--tr-bubble-content-items-gap` | 内容项之间的间距(仅当 `content` 属性是数组时有效) | +| 变量名 | 说明 | +| -------------------------------------- | ----------------------------------------------------------- | +| `--tr-bubble-box-bg` | Box 背景色 | +| `--tr-bubble-box-padding` | Box 内边距 | +| `--tr-bubble-box-border-radius` | Box 圆角大小 | +| `--tr-bubble-box-shadow` | Box 阴影效果 | +| `--tr-bubble-box-border` | Box 边框样式 | +| `--tr-bubble-box-shape-rounded-radius` | rounded 形状气泡圆角 | +| `--tr-bubble-box-shape-corner-radius` | corner 形状气泡的特定角圆角(start 为左上角,end 为右上角) | +| `--tr-bubble-box-image-padding` | 图片类型 Box 的内边距 | +| `--tr-bubble-box-image-border` | 图片类型 Box 的边框样式 | -**text 文本**(仅当 `content` 属性是字符串时有效) +**text 文本** | 变量名 | 说明 | | ------------------------------ | ------------ | @@ -275,22 +596,43 @@ abstract class BubbleContentClassRenderer { **loading 加载** -| 变量名 | 说明 | -| -------------------------- | ------------ | -| `--tr-bubble-loading-size` | 加载图标尺寸 | - -**aborted 中止状态** - -| 变量名 | 说明 | -| ------------------------------- | ------------ | -| `--tr-bubble-aborted-color` | 中止文字颜色 | -| `--tr-bubble-aborted-font-size` | 中止文字字号 | - -**footer 底部** - -| 变量名 | 说明 | -| --------------------------- | ---------- | -| `--tr-bubble-footer-margin` | 底部外边距 | +| 变量名 | 说明 | +| --------------------------- | ------------ | +| `--tr-bubble-loading-color` | 加载图标颜色 | +| `--tr-bubble-loading-size` | 加载图标尺寸 | + +**image 图片** + +| 变量名 | 说明 | +| ------------------------------------------ | --------------------------------- | +| `--tr-bubble-image-max-width` | 图片最大宽度 | +| `--tr-bubble-image-max-height` | 图片最大高度 | +| `--tr-bubble-image-border-radius` | 图片圆角大小 | +| `--tr-bubble-image-space-y` | 图片之间的垂直间距 | +| `--tr-bubble-image-embedded-border` | 嵌入在其他 box 中的图片边框样式 | +| `--tr-bubble-image-embedded-border-radius` | 嵌入在其他 box 中的图片圆角大小 | +| `--tr-bubble-image-embedded-margin-block` | 嵌入在其他 box 中的图片垂直外边距 | + +**tool 工具调用** + +| 变量名 | 说明 | +| ---------------------------------- | ---------------------------------- | +| `--tr-bubble-tool-call-bg` | 工具调用背景色 | +| `--tr-bubble-tool-call-space-y` | 工具调用之间的垂直间距 | +| `--tr-bubble-tool-call-min-width` | 工具调用的最小宽度 | +| `--tr-bubble-tool-call-max-width` | 工具调用的最大宽度 | +| `--tr-bubble-tool-call-max-height` | 工具调用详情最大高度(默认 300px) | +| `--tr-bubble-tool-key-color` | 工具调用 JSON 中 key 的颜色 | +| `--tr-bubble-tool-number-color` | 工具调用 JSON 中数字的颜色 | +| `--tr-bubble-tool-string-color` | 工具调用 JSON 中字符串的颜色 | +| `--tr-bubble-tool-boolean-color` | 工具调用 JSON 中布尔值的颜色 | +| `--tr-bubble-tool-null-color` | 工具调用 JSON 中 null 的颜色 | + +**reasoning 推理** + +| 变量名 | 说明 | +| ---------------------------------- | ------------------------------ | +| `--tr-bubble-reasoning-max-height` | 推理内容最大高度(默认 300px) | **BubbleList 容器变量** diff --git a/docs/src/migration/bubble-migration.md b/docs/src/migration/bubble-migration.md new file mode 100644 index 000000000..1ffae64f2 --- /dev/null +++ b/docs/src/migration/bubble-migration.md @@ -0,0 +1,414 @@ +--- +outline: [1, 3] +--- + +# Bubble 迁移指南 + +本文档用于将 **v0.3.x** 的 Bubble 组件用法迁移到 **0.4.x Bubble**。 + +## 核心变化概览 + +- **数据模型变化(最重要)**: + - v0.3.x:`BubbleList` 使用 `items`(每条 item 就是一条 bubble) + - 0.4.x:`BubbleList` 使用 `messages`(聊天消息模型,支持分组、状态、推理、工具调用等) +- **渲染体系升级**: + - v0.3.x:`BubbleProvider` 通过 `contentRenderers: Record` 注册内容渲染器(按 `content[i].type` 命中) + - 0.4.x:`BubbleProvider` 通过 **match rules** 配置渲染器: + - **Box 渲染器**:控制外层容器(样式/布局) + - **Content 渲染器**:控制内容(文本/图片/markdown/工具/推理等) + - 通过 `priority` + `find()` 进行匹配,未命中使用 `fallback*Renderer` +- **分组与插槽语义变化**: + - 0.4.x `BubbleList` 默认会把消息**按策略分组**(同角色连续/分割角色/自定义函数) + - 插槽从“单条 bubble”切换为“分组 messages / messageIndexes”语义 +- **能力增强**: + - 新增 `state` + `state-change`(存储 UI 状态且不污染原始消息) + - 新增 `contentResolver` / `contentRenderMode`(支持从任意字段解析内容、支持数组内容 split 渲染) + - 新增内置 renderers:`Image / Markdown / Loading / Reasoning / Tool / Tools / ToolRole ...` + +## API 对照表(常用项) + +### BubbleList + +| v0.3.x | 0.4.x | 说明 | +| --- | --- | --- | +| `items: (BubbleProps & { slots? })[]` | `messages: BubbleMessage[]` | **必改**:数据结构变化 | +| `roles?: Record` | `roleConfigs?: Record` | 命名变更 + 配置项变化 | +| `loading?: boolean` + `loadingRole?: string` | `messages` 中使用 `{ loading: true }` 或使用渲染器匹配 | **推荐**:把 loading 当作一条消息 | +| `autoScroll?: boolean` | `autoScroll?: boolean` | 行为增强:会监听 content/reasoning 等变化 | +| (无) | `groupStrategy?: 'consecutive' \| 'divider' \| (fn)` | **新增**:分组策略(默认 `divider`) | +| (无) | `dividerRole?: string` | `'divider'` 策略分割角色(默认 `'user'`) | +| (无) | `fallbackRole?: string` | 消息 role 缺失时使用(默认 `'assistant'`) | +| (无) | `contentResolver?: (message) => content` | 替代 v0.3.x 的 `customContentField` 思路 | +| (无) | `contentRenderMode?: 'single' \| 'split'` | 数组内容可“单框/多框”渲染 | + +### Bubble + +| v0.3.x | 0.4.x | 说明 | +| --- | --- | --- | +| `content?: string \| BubbleContentItem[]` | `content?: string \| ChatMessageContentItem[]` | 类型名变化(语义相同) | +| `avatar?: VNode` | `avatar?: VNode \| Component` | 支持直接传组件 | +| `shape?: 'rounded' \| 'corner'` | `shape?: 'corner' \| 'rounded' \| 'none'` | **新增** `none` | +| `aborted?: boolean` + `abortedText?: string` | (无同名) | 旧“aborted 文案”不再是核心能力,建议用自定义渲染器/插槽实现 | +| `customContentField?: string` | `contentResolver?: (message) => content` | **替代**:从任意字段解析内容 | +| `maxWidth?: string \| number` | 使用 CSS 变量 `--tr-bubble-max-width` 等 | 0.4.x 把宽度控制放到 box 变量体系 | +| `contentRenderer?: BubbleContentRenderer` | `fallbackContentRenderer?: Component` | 单组件 fallback(仅当没有匹配到规则时使用) | +| (无) | `fallbackBoxRenderer?: Component` | 新增:box fallback | +| (无) | `state?: Record` | 新增:UI 状态 | + +### Slots(命名与参数变化) + +#### v0.3.x(Bubble) + +- `default / footer / loading / trailer` +- slot 参数:`{ bubbleProps, index? }` + +#### 0.4.x(Bubble) + +- `prefix / suffix / after / content-footer` +- slot 参数:`{ messages: BubbleMessage[]; role?: string; contentIndex? }` + +#### 0.4.x(BubbleList) + +- `prefix / suffix / after / content-footer` +- slot 参数额外包含:`messageIndexes: number[]`(该分组对应的原始消息索引集合) + +## 迁移步骤 + +### 1) 将 `items` 迁移为 `messages` + +v0.3.x 示例(可直接参考写法): + +```vue + + + +``` + +0.4.x 推荐写法:把 loading 变成一条消息(或由匹配规则处理): + +```vue + + + +``` + +消息结构示例: + +```ts +const messages = [ + { role: 'assistant', content: 'Hello' }, + { role: 'assistant', loading: true }, // Loading as a message (recommended) +] +``` + +> 提示:0.4.x `BubbleMessage` 还支持 `reasoning_content / tool_calls / tool_call_id / name / state`,可直接承载大模型输出结构(OpenAI 风格)。 + +### 2) `roles` → `roleConfigs`,并迁移 `hidden` + +v0.3.x: + +- `roles[role].hidden`:隐藏该 role 所有消息 + +0.4.x: + +- 仍支持 `roleConfigs[role].hidden` +- 但**分组规则**会对 hidden 做特殊处理:连续 hidden 消息可归为同组(与非 hidden 分开) + +### 3) `customContentField` → `contentResolver` + +v0.3.x 的 `customContentField` 是“从 attrs 的某个字段取内容优先渲染”。 + +0.4.x 推荐用 `contentResolver` 来统一解决“从原始消息中抽取/派生要渲染的 content”: + +```vue + +``` + +对应 v0.3.x `customContentField` 的常见迁移(从“item attrs”迁移到“message 字段”): + +```ts +// v0.3.x idea: +// - bubble.customContentField = 'my-content' +// - bubble['my-content'] is the real content to render +// +// latest idea: +// - put it into the message directly, and resolve it via contentResolver +const messages = [ + { + role: 'ai', + content: 'Raw model content (kept untouched)', + 'my-content': [{ type: 'text', content: 'UI-ready content' }], + }, +] +``` + +### 4) loading 的迁移(`loadingRole` 移除) + +v0.3.x: + +- `BubbleList` 的 loading 并不是一条消息,而是额外渲染一个 loading bubble,并由 `loadingRole` 决定样式/slot。 + +0.4.x 建议: + +- **方式 A(推荐)**:把 loading 当作一条消息:`{ role: 'assistant', loading: true }` +- **默认行为**:0.4.x **内置**了 loading 的匹配规则与渲染器(基于 `message.loading` 命中),通常**不需要**你手动配置。 +- **只有在你想自定义 loading UI**(样式/结构/动画等)时,才需要用 `BubbleProvider` 覆盖 loading 的匹配规则或 fallback 渲染器。 + +自定义 loading UI 示例(provider 覆盖默认 loading 渲染): + +```vue + + + +``` + +### 5) “中止 aborted” 的迁移建议 + +v0.3.x 通过 `aborted` / `abortedText` 内置展示“(用户停止)”。 + +0.4.x 没有同名 API。建议做法: + +- 将“停止”视为一种消息状态/内容类型:比如在消息 `state` 或 `content` 中携带标记 +- 用 **Content renderer match** 或 `content-footer` 插槽来渲染“已停止/已取消”等 UI + +示例(思路): + +```ts +{ role: 'assistant', content: '...', state: { aborted: true } } +``` + +然后在自定义 renderer / 插槽里判断 `message.state?.aborted`。 + +一个最小可用的“aborted 文案”迁移示例(用 `content-footer` 插槽渲染): + +```vue + + + +``` + +### 6) 渲染器迁移:`contentRenderers` Map → `contentRendererMatches` + +#### v0.3.x 机制 + +- 仅当 `content` 是非空数组时,按 `content[i].type` 在 provider 的 `contentRenderers` Map 中找 renderer。 +- 找不到时 fallback 为 `text`。 + +#### 0.4.x 机制(match rules) + +通过 `BubbleProvider` 提供 `contentRendererMatches`(以及可选的 `boxRendererMatches`): + +- `find(message, resolvedContent, contentIndex) => boolean` +- 按 `priority` 从小到大执行,命中第一个即使用 +- 未命中使用 `fallbackContentRenderer` + +迁移思路(把 “按 type 命中” 变成 “按 type 匹配”): + +```ts +const matches = [ + { + // priority 可不写,默认 0;建议按需求设置更细的优先级 + find: (_message, resolvedContent, contentIndex) => { + const item = Array.isArray(resolvedContent) ? resolvedContent[contentIndex ?? 0] : null + return Boolean(item && typeof item === 'object' && item.type === 'my-type') + }, + renderer: MyRendererComponent, + }, +] +``` + +完整示例(把 v0.3.x 的 `contentRenderers['my-type']` 迁移到 provider match): + +```vue + + + +``` + +> 注意:0.4.x 内置了不少 renderer(图片/markdown/工具/推理等),如果你在 v0.3.x 自己实现过这些类型,迁移时可以优先改为直接使用 `BubbleRenderers.*`。 + +### 7) 插槽迁移(slot 名称与参数变化) + +常见迁移: + +- v0.3.x `footer` / `trailer` → 0.4.x `content-footer` / `after` +- v0.3.x `loading` slot → 用 loading message + renderer/slot 实现 + +因为 0.4.x slot 参数是 **分组 messages**(不是单条 bubbleProps),如果你需要单条 message: + +- 单 bubble:`messages[0]` 就是当前消息 +- list 分组:遍历 `messages`,或配合 `messageIndexes` 反查原始消息数组 + +slot 改名示例(v0.3.x `footer` → 0.4.x `content-footer`): + +```vue + + + + +``` + +```vue + + + + +``` + +### 8) CSS 变量迁移(最常用的几个) + +v0.3.x 主要围绕 `content`: + +- `--tr-bubble-content-bg` +- `--tr-bubble-content-border-radius` +- `--tr-bubble-content-padding` + +0.4.x 改为围绕 `box`: + +- `--tr-bubble-box-bg` +- `--tr-bubble-box-border-radius` +- `--tr-bubble-box-padding` + +> 注意:如果你在项目里像 demo 那样写了 `--tr-bubble-content-bg`,迁移到新版后应优先改为 `--tr-bubble-box-bg`(新版的“气泡背景”属于 box 层)。 + +并新增: + +- `--tr-bubble-min-width` +- `--tr-bubble-box-shape-rounded-radius / --tr-bubble-box-shape-corner-radius` +- 图片/工具/推理相关变量(详见 `bubble.md`) + +## 推荐迁移检查清单 + +- [ ] `BubbleList.items` 全部替换为 `messages` +- [ ] `roles` 重命名为 `roleConfigs` +- [ ] `loading + loadingRole` 改为 “loading message” 或 provider match +- [ ] `customContentField` 改为 `contentResolver` +- [ ] `aborted` 逻辑改为 `state` + 自定义渲染/插槽 +- [ ] 旧插槽名全部替换为新插槽名,并适配 slot 参数(`messages` / `messageIndexes`) +- [ ] 样式变量从 `content-*` 迁移到 `box-*` diff --git a/docs/src/migration/use-conversation-migration.md b/docs/src/migration/use-conversation-migration.md new file mode 100644 index 000000000..99bfbd466 --- /dev/null +++ b/docs/src/migration/use-conversation-migration.md @@ -0,0 +1,72 @@ +--- +outline: [1, 3] +--- + +# useConversation 迁移 + +本文档用于将 `useConversation` 从 **v0.3.x** 迁移到 **0.4.x**:以 `useMessageOptions` 替代 `client`,每个会话拥有独立的 `useMessage` engine,支持懒加载、自动保存节流与存储策略拆分。 + +## 概述 + +- **v0.3.x**:单一 `messageManager` + 一套会话状态(数组 + currentId) +- **0.4.x**:以 `useMessageOptions` 为核心,每个会话对应独立 `useMessage` engine;支持 `ConversationStorageStrategy` 的 `loadConversations` / `loadMessages` / `saveConversation` / `saveMessages` 拆分、懒加载与自动保存节流 + +## v0.3.x 用法 + +以下为 v0.3.x 的旧写法。 + +```ts +import { useConversation, AIClient } from '@opentiny/tiny-robot-kit' + +const client = new AIClient({ provider: 'openai', apiKey: 'xxx' }) + +const { state, messageManager, createConversation, switchConversation } = useConversation({ client }) +``` + +## 0.4.x 用法 + +以下为 0.4.x 的写法。`useConversation` 以 `useMessageOptions` 为核心,每个会话都会有自己的 `engine`: + +```ts +import { useConversation } from '@opentiny/tiny-robot-kit' + +const { conversations, activeConversationId, activeConversation, createConversation, switchConversation } = useConversation({ + useMessageOptions: { + responseProvider, + }, + autoSaveMessages: true, + autoSaveThrottle: 1000, +}) + +createConversation({ title: 'New chat' }) +await switchConversation(conversations.value[0].id) +activeConversation.value?.engine.sendMessage('Hello') +``` + +## v0.3.x → 0.4.x 对照 + +| v0.3.x | 0.4.x | 说明 | +| --- | --- | --- | +| `{ client }` | `{ useMessageOptions: { responseProvider } }` | 不再传入 AIClient,改为 useMessage 的配置 | +| `state` + `messageManager` | `conversations` + `activeConversationId` + `activeConversation` | 会话列表与当前会话拆分为独立 ref;`activeConversation.engine` 即该会话的 useMessage 实例 | +| 单一 messageManager | 每个会话独立 `engine`(懒加载) | 切换会话时按需创建/加载 engine,支持后台请求不中断 | +| (无) | `autoSaveMessages`、`autoSaveThrottle` | 可选自动保存与节流 | +| (无) | `storage?: ConversationStorageStrategy` | 可选的存储策略,接口见下方 | + +## 存储策略迁移 + +v0.3.x 的 storage 更偏「保存整个 conversations」;0.4.x 的 `ConversationStorageStrategy` 拆分为: + +- `loadConversations()`:只加载会话列表(id / title / metadata / 时间) +- `loadMessages(conversationId)`:加载指定会话的 messages +- `saveConversation(conversation)`:保存会话元信息 +- `saveMessages(conversationId, messages)`:保存 messages + +如有自定义存储,实现 0.4.x 的 `ConversationStorageStrategy` 即可(可复用既有持久化介质)。详见 [会话数据管理 - 存储策略](/tools/conversation#存储策略接口)。 + +## 迁移检查清单 + +- [ ] `useConversation({ client })` 改为 `useConversation({ useMessageOptions: { responseProvider } })` +- [ ] 将依赖 `state` / `messageManager` 的逻辑改为使用 `conversations`、`activeConversationId`、`activeConversation` 与 `activeConversation.engine` +- [ ] 若有自定义存储,按 `ConversationStorageStrategy` 实现 `loadConversations`、`loadMessages`、`saveConversation`、`saveMessages` +- [ ] 包导出变化(如 `formatMessages` 等移除)见 [useMessage 迁移](./use-message-migration#导出与导入路径迁移) diff --git a/docs/src/migration/use-message-migration.md b/docs/src/migration/use-message-migration.md new file mode 100644 index 000000000..25266b4ce --- /dev/null +++ b/docs/src/migration/use-message-migration.md @@ -0,0 +1,151 @@ +--- +outline: [1, 3] +--- + +# useMessage 迁移 + +本文档用于将 `useMessage` 从 **v0.3.x** 迁移到 **0.4.x**:以 `responseProvider` 替代 `client`,并引入 `requestState` / `processingState` 与插件体系。 + +## 概述 + +- **v0.3.x**:`useMessage({ client, useStreamByDefault, events... })`,内部直接调用 `client.chat` / `client.chatStream` +- **0.4.x**:`useMessage({ responseProvider, plugins... })`,由你提供数据源(Promise 或 AsyncGenerator),框架负责状态机、合并与扩展点;内置 `fallbackRolePlugin`、`thinkingPlugin`、`lengthPlugin`,工具调用使用 `toolPlugin` + +## v0.3.x 用法 + +以下为 v0.3.x 的旧写法。 + +```ts +import { AIClient, useMessage } from '@opentiny/tiny-robot-kit' + +const client = new AIClient({ provider: 'openai', apiKey: 'xxx' }) + +const { + messages, + messageState, + inputMessage, + useStream, + sendMessage, + abortRequest, + retryRequest, +} = useMessage({ + client, + useStreamByDefault: true, + errorMessage: 'Request failed.', +}) +``` + +## 0.4.x 用法 + +以下为 0.4.x 的写法。需要提供 `responseProvider(requestBody, abortSignal)`: + +- **返回 `Promise`**:一次性返回完整响应(非流式) +- **返回 `AsyncGenerator`**:以流式/分块方式返回多个 chunk + +### 非流式示例 + +```ts +import { useMessage } from '@opentiny/tiny-robot-kit' +import type { MessageRequestBody } from '@opentiny/tiny-robot-kit' + +const responseProvider = async (requestBody: MessageRequestBody, abortSignal: AbortSignal) => { + const resp = await fetch('/your-api/chat-completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signal: abortSignal, + }) + return await resp.json() +} + +const { messages, requestState, processingState, isProcessing, sendMessage, send, abortRequest } = useMessage({ + initialMessages: [], + responseProvider, +}) +``` + +### 流式示例 + +使用 `sseStreamToGenerator` 将 SSE 转为 AsyncGenerator: + +```ts +import { sseStreamToGenerator, useMessage } from '@opentiny/tiny-robot-kit' +import type { MessageRequestBody } from '@opentiny/tiny-robot-kit' + +const responseProvider = async (requestBody: MessageRequestBody, abortSignal: AbortSignal) => { + const resp = await fetch('/your-api/chat-completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...requestBody, stream: true }), + signal: abortSignal, + }) + return sseStreamToGenerator(resp, { signal: abortSignal }) +} + +const engine = useMessage({ responseProvider }) +``` + +## v0.3.x → 0.4.x 对照 + +| v0.3.x | 0.4.x | 说明 | +| --- | --- | --- | +| `messageState.status`(`STATUS` enum) | `requestState` + `processingState` | 状态机拆分:宏观状态 + 处理阶段 | +| `useStream` | 由 `responseProvider` 决定 | 0.4.x 不内置 stream 开关 | +| `inputMessage` | 不再内置 | 建议在业务层自己维护输入框状态 | +| `retryRequest(msgIndex)` | 不再内置 | 推荐通过插件/业务逻辑实现「回滚并重试」 | +| `events.onReceiveData` / `onFinish` | `onCompletionChunk` + plugin hooks | 更强的扩展点体系 | + +## 插件迁移建议 + +0.4.x 默认会注入基础插件(role fallback、thinking、length 等)。可通过 `plugins` 追加能力,或通过同名插件覆盖/禁用默认行为。 + +工具调用推荐使用内置 `toolPlugin`: + +```ts +import { toolPlugin, useMessage } from '@opentiny/tiny-robot-kit' + +const engine = useMessage({ + responseProvider, + plugins: [ + toolPlugin({ + getTools: async () => [ + { + type: 'function', + function: { + name: 'getWeather', + description: 'Get weather by city name.', + parameters: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + }, + }, + }, + ], + callTool: async (toolCall) => { + const args = JSON.parse(toolCall.function.arguments || '{}') + return `Weather of ${args.city}: Sunny` + }, + toolCallCancelledContent: 'Tool call cancelled.', + toolCallFailedContent: 'Tool call failed.', + }), + ], +}) +``` + +## 导出与导入路径迁移 + +| v0.3.x(根导出) | 0.4.x(根导出) | 备注 | +| --- | --- | --- | +| `AIClient` | `AIClient`(deprecated) | 推荐改用 `responseProvider` | +| `BaseModelProvider` / `OpenAIProvider` | **不再从根导出** | 如确有需要请改为内部路径导入(不推荐) | +| `formatMessages` / `extractTextFromResponse` / `handleSSEStream` | **不再从根导出** | 0.4.x 根导出提供 `sseStreamToGenerator` | +| (无) | `export * from './storage'` | 新增:根导出 storage 能力 | +| `export * from './vue'` | 分拆导出 `useMessage` / `useConversation` + `message/types` + `plugins` | 导出粒度更清晰 | + +## 迁移检查清单 + +- [ ] `useMessage({ client })` 改为 `useMessage({ responseProvider })` +- [ ] 依赖 `STATUS` / `messageState` / `inputMessage` / `retryRequest` 的,改为基于 `requestState` / `isProcessing` 的 UI 状态,并自行维护输入/重试逻辑(或写插件) +- [ ] 需要 tools 时,使用 `toolPlugin` 或自定义插件 +- [ ] 若从根导入 `formatMessages`、`extractTextFromResponse`、`handleSSEStream` 或 `BaseModelProvider` / `OpenAIProvider`,改为新导出或业务层实现 diff --git a/docs/src/tools/ai-client.md b/docs/src/tools/ai-client.md index bb5bcde02..738eba870 100644 --- a/docs/src/tools/ai-client.md +++ b/docs/src/tools/ai-client.md @@ -4,7 +4,13 @@ outline: deep # AI模型交互工具类 AIClient -客户端类,用于与AI模型交互。 +:::danger 重大版本升级 v0.4 +`AIClient` 已废弃,推荐使用 `useMessage` + `responseProvider`。 + +**从 v0.3.x 升级?** 请查看 [useMessage 迁移](../migration/use-message-migration)。 +::: + +客户端类,用于与 AI 模型交互(已废弃,仅作兼容保留)。 ## 用法示例 @@ -80,7 +86,7 @@ declare class AIClient { interface StreamHandler { onData: (data: ChatCompletionStreamResponse) => void; onError: (error: AIAdapterError) => void; - onDone: () => void; + onDone: (finishReason?: string) => void; } ``` diff --git a/docs/src/tools/conversation.md b/docs/src/tools/conversation.md index e5d0fce3f..fc9c3de96 100644 --- a/docs/src/tools/conversation.md +++ b/docs/src/tools/conversation.md @@ -1,59 +1,78 @@ --- -outline: deep +outline: [1, 3] --- # 对话管理 useConversation -`useConversation` 是一个对话管理工具,它可以帮助你管理对话的状态和历史记录。 +:::danger 重大版本升级 v0.4 +useConversation 在 v0.4 进行了重大升级,`client` 改为 `useMessageOptions`,存储与引擎懒加载有变。 + +**从 v0.3.x 升级?** 请查看 [useConversation 迁移](../migration/use-conversation-migration)。 + +**新项目:** 直接使用下方 v0.4 的 API 和示例即可。 +::: + +`useConversation` 是一个对话管理工具,它可以帮助你管理对话的状态和历史记录。下方示例覆盖对话管理及存储策略的常见场景,可直接在项目或文档中运行。 ## 示例 ### 基础示例 -使用 Mock 存储策略的基础示例,适合快速了解功能: +使用 `useConversation` 管理多会话,配合 `tr-bubble-list` 展示消息、`tr-sender` 输入发送。每个会话拥有独立的 useMessage 引擎,切换会话时,当前会话的请求可在后台继续执行,支持多会话并行处理。本示例使用内存模拟存储和模拟流式响应,预置若干会话和消息,无需真实 API 即可体验切换会话、创建新对话、发送消息等完整流程。 + + - +### 存储 -### LocalStorage 策略 +默认情况下,`useConversation` 会使用 LocalStorage 策略来持久化会话和消息数据。如需更大容量或更好性能,可切换到 IndexedDB 策略,或实现自定义存储策略。 -使用浏览器 LocalStorage 存储会话数据,刷新页面后数据仍然保留: +#### LocalStorage 策略 + +使用浏览器 LocalStorage 存储会话数据,适合小量数据存储。会话和消息会持久化到本地,刷新页面后仍可恢复。 -### IndexedDB 策略 +#### IndexedDB 策略 -使用浏览器 IndexedDB 存储会话数据,支持更大容量和更好性能: +使用浏览器 IndexedDB 存储会话数据,支持更大容量和更好性能。适用于大量会话或长对话历史场景。 +#### 自定义存储策略 + +实现自定义存储策略,例如将数据保存到远程服务器。本示例使用内存存储作为演示,刷新页面后数据会丢失。 + + + ## API ### 选项 + ```typescript interface UseConversationOptions { - /** AI客户端实例 */ - client: AIClient - /** 存储策略(可选,默认使用 LocalStorage) */ + /** + * 所有会话的基础 useMessage 选项。 + * 传递给 createConversation 的每个会话选项会在此基础上合并。 + */ + useMessageOptions: UseMessageOptions + /** + * 是否在消息变更时自动保存。 + * @default false + */ + autoSaveMessages?: boolean + /** + * 自动保存操作的节流时间(毫秒)。 + * 确保在流式更新期间,每个时间间隔内最多保存一次消息。 + * 仅在 autoSaveMessages 为 true 时生效。 + * @default 1000 + */ + autoSaveThrottle?: number + /** + * 可选的存储策略,用于会话和消息的持久化。 + * 如果不提供,默认使用 LocalStorage 策略。 + * 当提供时,会话列表和消息可以被加载和持久化。 + */ storage?: ConversationStorageStrategy - /** 是否自动保存 (default: true) */ - autoSave?: boolean - /** 是否允许空会话 (default: false) */ - allowEmpty?: boolean - /** 是否默认使用流式响应 (default: true)*/ - useStreamByDefault?: boolean - /** 错误消息模板 */ - errorMessage?: string - /** 事件回调 */ - events?: UseConversationEvents -} -``` - -### 事件类型 - -```typescript -type UseConversationEvents = UseMessageOptions['events'] & { - /** 会话加载完成回调 */ - onLoaded?: (conversations: Conversation[]) => void } ``` @@ -61,178 +80,141 @@ type UseConversationEvents = UseMessageOptions['events'] & { ```typescript interface UseConversationReturn { - /** 会话状态 */ - state: ConversationState; - /** 消息管理 */ - messageManager: UseMessageReturn; + /** 会话列表 */ + conversations: Ref + /** 当前会话ID */ + activeConversationId: Ref + /** 当前活跃会话 */ + activeConversation: ComputedRef /** 创建新会话 */ - createConversation: (title?: string, metadata?: Record) => string; + createConversation: (params?: { + /** 会话ID,不提供则自动生成 */ + id?: string + /** 会话标题 */ + title?: string + /** 自定义元数据 */ + metadata?: Record + /** 覆盖默认的消息选项 */ + useMessageOptions?: Partial + }) => Conversation /** 切换会话 */ - switchConversation: (id: string) => void; + switchConversation: (id: string) => Promise /** 删除会话 */ - deleteConversation: (id: string) => void; + deleteConversation: (id: string) => Promise + /** 清空所有会话 */ + clear: () => void /** 更新会话标题 */ - updateTitle: (id: string, title: string) => void; - /** 更新会话元数据 */ - updateMetadata: (id: string, metadata: Record) => void; - /** 保存会话 */ - saveConversations: () => Promise; - /** 加载会话 */ - loadConversations: () => Promise; - /** 生成会话标题 */ - generateTitle: (id: string) => Promise; - /** 获取当前会话 */ - getCurrentConversation: () => Conversation | null; -} -``` - -### 会话状态 - -```typescript -interface ConversationState { - /** 会话列表 */ - conversations: Conversation[]; - /** 当前会话ID */ - currentId: string | null; - /** 是否正在加载 */ - loading: boolean; + updateConversationTitle: (id: string, title?: string) => void + /** 保存指定会话的消息 */ + saveMessages: (id?: string) => void + /** 发送消息到当前活跃会话 */ + sendMessage: (content: string) => void + /** 中止当前活跃会话的请求 */ + abortActiveRequest: () => Promise } ``` ### 会话接口 ```typescript - -interface Conversation { +interface ConversationInfo { /** 会话ID */ - id: string; + id: string /** 会话标题 */ - title: string; + title?: string /** 创建时间 */ - createdAt: number; + createdAt: number /** 更新时间 */ - updatedAt: number; + updatedAt: number /** 自定义元数据 */ - metadata?: Record; - /** 消息 */ - messages: ChatMessage[]; + metadata?: Record } -``` - -### 存储策略 +interface Conversation extends ConversationInfo { + /** + * 由 useMessage 创建的消息引擎实例。 + */ + engine: UseMessageReturn +} +``` -#### 使用 LocalStorage(默认) +### 存储策略接口 -默认情况下,会话数据存储在浏览器的 LocalStorage 中: +所有存储策略都需要实现 `ConversationStorageStrategy` 接口: ```typescript -const conversationManager = useConversation({ - client, - // 默认使用 LocalStorage,无需配置 -}); +interface ConversationStorageStrategy { + /** + * 加载所有会话(仅包含元数据) + */ + loadConversations: () => MaybePromise + + /** + * 加载指定会话的所有消息 + */ + loadMessages: (conversationId: string) => MaybePromise + + /** + * 保存或更新会话元数据 + */ + saveConversation: (conversation: ConversationInfo) => MaybePromise + + /** + * 保存指定会话的消息 + */ + saveMessages: (conversationId: string, messages: ChatMessage[]) => MaybePromise + + /** + * 删除会话及其所有消息(可选) + */ + deleteConversation?: (conversationId: string) => MaybePromise +} ``` -#### 使用 LocalStorage 自定义配置 - -```typescript -import { localStorageStrategyFactory } from '@opentiny/tiny-robot-kit' - -const conversationManager = useConversation({ - client, - storage: localStorageStrategyFactory({ - key: 'my-app-conversations' - }) -}); -``` +### 存储策略工厂函数 -#### 使用 IndexedDB +#### localStorageStrategyFactory -IndexedDB 相比 LocalStorage 具有更大的存储容量(>50MB)和更好的性能,适合存储大量会话数据: +创建 LocalStorage 存储策略实例。 ```typescript -import { indexedDBStorageStrategyFactory } from '@opentiny/tiny-robot-kit' - -const conversationManager = useConversation({ - client, - storage: indexedDBStorageStrategyFactory({ - dbName: 'my-chat-app-db', - dbVersion: 1 - }) -}); +function localStorageStrategyFactory(config?: LocalStorageConfig): ConversationStorageStrategy ``` -#### 存储策略对比 - -| 特性 | LocalStorage | IndexedDB | -|------|-------------|-----------| -| 存储容量 | ~5-10MB | >50MB | -| 性能 | 同步操作 | 异步操作,不阻塞主线程 | -| 数据类型 | 仅字符串(需 JSON 序列化) | 支持对象、数组、二进制 | -| 查询能力 | 简单 key-value | 支持索引和复杂查询 | -| 浏览器支持 | 所有现代浏览器 | 所有现代浏览器(不支持 IE) | -| 隐私模式 | ✅ 支持 | ⚠️ 受限(见下方说明) | -| 适用场景 | 少量会话(<100个) | 大量会话或长对话历史 | - -#### 重要提示:隐私/无痕模式限制 +##### 参数 -**IndexedDB 在隐私模式下的行为**: +```typescript +interface LocalStorageConfig { + /** 存储键名,默认为 'tiny-robot-ai-conversations' */ + key?: string +} +``` -不同浏览器在隐私/无痕模式下对 IndexedDB 的支持有所不同: +#### indexedDBStorageStrategyFactory -- **Chrome/Edge 隐私模式**:IndexedDB 可用,但数据在关闭浏览器后会被清除 -- **Firefox 隐私模式**:IndexedDB 可用,但存储配额较小 -- **Safari 隐私模式**:IndexedDB **完全不可用**,会抛出错误 +创建 IndexedDB 存储策略实例。 -#### 自定义存储策略 +```typescript +function indexedDBStorageStrategyFactory(config?: IndexedDBConfig): ConversationStorageStrategy +``` -你也可以实现自定义的存储策略,例如将数据保存到远程服务器: +##### 参数 ```typescript -import type { ConversationStorageStrategy, Conversation } from '@tiny-robot/kit'; - -// 远程存储策略示例 -class RemoteStorageStrategy implements ConversationStorageStrategy { - private apiUrl: string; - - constructor(apiUrl: string) { - this.apiUrl = apiUrl; - } - - async saveConversations(conversations: Conversation[]): Promise { - await fetch(`${this.apiUrl}/conversations`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(conversations) - }); - } - - async loadConversations(): Promise { - const response = await fetch(`${this.apiUrl}/conversations`); - return response.json(); - } - - async clear(): Promise { - await fetch(`${this.apiUrl}/conversations`, { method: 'DELETE' }); - } +interface IndexedDBConfig { + /** 数据库名称,默认为 'tiny-robot-ai-db' */ + dbName?: string + /** 数据库版本,默认为 1 */ + dbVersion?: number } - -// 使用自定义存储策略 -const conversationManager = useConversation({ - client, - storage: new RemoteStorageStrategy('https://api.example.com') -}); ``` -### 存储策略接口 +### 类型定义 + +#### MaybePromise ```typescript -interface ConversationStorageStrategy { - /** 保存会话列表 */ - saveConversations: (conversations: Conversation[]) => Promise | void; - /** 加载会话列表 */ - loadConversations: () => Promise | Conversation[]; - /** 清空所有会话(可选) */ - clear?: () => Promise | void; -} +type MaybePromise = T | Promise ``` + +存储策略的方法可以返回同步值或 Promise,框架会自动处理。 diff --git a/docs/src/tools/message.md b/docs/src/tools/message.md index 812beceb1..605197776 100644 --- a/docs/src/tools/message.md +++ b/docs/src/tools/message.md @@ -1,46 +1,68 @@ --- -outline: deep +outline: [1, 3] --- # 消息与数据管理 useMessage +:::danger 重大版本升级 v0.4 +useMessage 在 v0.4 进行了重大升级,`client` 改为 `responseProvider`,状态与插件体系有变。 + +**从 v0.3.x 升级?** 请查看 [useMessage 迁移](../migration/use-message-migration)。 + +**新项目:** 直接使用下方 v0.4 的 API 和示例即可。 +::: + +`useMessage` 是一个用于管理消息状态和处理 AI 响应的组合式函数。它提供了完整的消息管理功能,包括发送消息、处理流式响应、管理请求状态等。 + ## 示例 +以下示例覆盖 AI 消息交互中常见场景,可直接在项目或文档中运行。 + ### 基础用法 - +使用 `responseProvider` 发起流式请求,配合 `initialMessages` 展示欢迎语。当后端返回 SSE(Server-Sent Events)流时,可使用 `sseStreamToGenerator` 工具函数将 `fetch` 的 `Response` 转为异步生成器(`AsyncGenerator`),供 `useMessage` 逐块消费并合并到消息内容中。 -### onReceiveData 事件 + -`onReceiveData` 的签名如下: +**非流式**:`responseProvider` 返回 `Promise`,一次性得到完整结果,适用于不支持 SSE 的后端(`stream: false`)。 -```ts -onReceiveData?: (data: T, messages: Ref, preventDefault: () => void) => void -``` + -`data` 是大模型响应, `messages` 是本地存储的对话记录, `preventDefault` 可以用来阻止默认行为。 +### 请求状态 -非流式 chat 内部的默认行为如下: +根据 `requestState`(idle / processing / completed / aborted / error)和 `processingState`(requesting / completing)驱动 UI:加载、禁用发送、展示错误等。 -```ts -const assistantMessage: ChatMessage = { - role: 'assistant', - content: data.choices[0].message.content, -} -messages.value.push(assistantMessage) -``` + -流式 chat 内部的默认行为如下: +### 修改请求参数 -```ts -if (messages.value[messages.value.length - 1].role === 'user') { - messages.value.push({ role: 'assistant', content: '' }) -} -const choice = data.choices?.[0] -if (choice && choice.delta.content) { - messages.value[messages.value.length - 1].content += choice.delta.content -} -``` +通过插件的 `onBeforeRequest` 钩子在请求前修改 `requestBody`(如注入 system 消息、追加 temperature 等参数)。可在 F12 开发者工具的「网络」面板中查看实际发出的请求体,验证修改是否生效。 + + + +### 错误处理 + +通过插件的 `onError` 钩子统一处理请求错误,例如向对话中追加一条“出错”的助手消息,避免未捕获异常。 + + + +### 模拟流式 + +使用不依赖真实 API 的 `responseProvider`(如本地 AsyncGenerator)模拟流式响应,便于离线开发与联调。 + + + +### 自定义 Chunk 处理 + +使用 `onCompletionChunk` 在收到每个响应块时做自定义逻辑(如统计、日志、转换),并调用 `runDefault()` 执行默认的内容合并。 + + + +### 工具调用 + +使用 `toolPlugin` 接入模型返回的 `tool_calls`:通过 `getTools` 注入工具列表,通过 `callTool` 执行工具并写入 tool 消息,插件会自动发起下一轮请求。本示例使用模拟 API 返回一次 `get_weather` 调用,无需真实后端。 + + ## API @@ -56,67 +78,237 @@ const messageComposable: UseMessageReturn = useMessage( ```typescript interface UseMessageOptions { - /** AI客户端实例 */ - client: AIClient - /** 是否默认使用流式响应 */ - useStreamByDefault?: boolean - /** 错误消息模板 */ - errorMessage?: string /** 初始消息列表 */ initialMessages?: ChatMessage[] - /** 事件回调 */ - events?: { - onReceiveData?: (data: T, messages: Ref, preventDefault: () => void) => void - } + /** + * 请求消息时,要包含的字段(白名单)。默认包含所有字段。 + * 如果 `requestMessageFieldsExclude` 存在,会先取 `requestMessageFields` 中的字段,再排除 `requestMessageFieldsExclude` 中的字段 + */ + requestMessageFields?: string[] + /** + * 请求消息时,要排除的字段(黑名单)。默认会排除 `state`、`metadata`、`loading` 字段(这几个字段是给UI展示用的)。 + * 如果 `requestMessageFields` 存在,会先取 `requestMessageFields` 中的字段,再排除 `requestMessageFieldsExclude` 中的字段 + */ + requestMessageFieldsExclude?: string[] + /** 插件列表 */ + plugins?: UseMessagePlugin[] + /** + * 响应提供者函数,负责发起请求并返回响应。 + * 可返回 Promise、AsyncGenerator 或 Promise + */ + responseProvider: ( + requestBody: MessageRequestBody, + abortSignal: AbortSignal, + ) => Promise | AsyncGenerator | Promise> + /** + * 全局的数据块处理钩子,在接收到每个响应数据块时触发。 + * 注意:此钩子与插件中的 onCompletionChunk 有区别。 + * 如果传入了此参数,默认的 chunk 处理逻辑不会自动执行,需要手动调用 runDefault 来执行默认处理逻辑。 + */ + onCompletionChunk?: ( + context: BasePluginContext & { + currentMessage: ChatMessage + choice: CompletionChoice + chunk: ChatCompletion + }, + runDefault: () => void, + ) => void } ``` +**responseProvider 返回值**:`responseProvider` 的返回值决定响应模式。返回 `Promise` 时,一次性得到完整结果,适用于非流式接口,`useMessage` 会将解析出的内容整体写入消息;返回 `AsyncGenerator` 或 `Promise>` 时,逐块产出数据,适用于流式接口(如 SSE),`useMessage` 会按块消费并增量合并到消息内容中。若后端返回 SSE 流,可使用 `sseStreamToGenerator` 将 `fetch` 的 `Response` 转为异步生成器。 + ### 返回值 `useMessage` 返回以下内容: ```typescript interface UseMessageReturn { + /** 请求状态 */ + requestState: Ref + /** 处理状态(如 'requesting' | 'completing') */ + processingState: Ref + /** 消息列表 */ messages: Ref - /** 消息状态 */ - messageState: Reactive - /** 输入消息 */ - inputMessage: Ref - /** 是否使用流式响应 */ - useStream: Ref + /** 响应提供者(可动态更新) */ + responseProvider: Ref + /** 是否正在处理中 */ + isProcessing: ComputedRef /** 发送消息 */ - sendMessage: (content?: ChatMessage['content'], clearInput?: boolean) => Promise - /** 手动执行addMessage添加消息后,可以执行send发送消息 */ - send: () => Promise - /** 清空消息 */ - clearMessages: () => void - /** 添加消息 */ - addMessage: (message: ChatMessage | ChatMessage[]) => void - /** 中止请求 */ - abortRequest: () => void - /** 重试请求 */ - retryRequest: (msgIndex: number) => Promise + sendMessage: (content: string) => Promise + /** 发送消息(支持传入多个消息对象) */ + send: (...msgs: ChatMessage[]) => Promise + /** 中止当前请求 */ + abortRequest: () => Promise } ``` -### MessageState 接口 +### 请求状态类型 ```typescript -interface MessageState { - status: STATUS - errorMsg: string | null -} +/** 请求状态 */ +type RequestState = 'idle' | 'processing' | 'completed' | 'aborted' | 'error' + +/** 处理状态 */ +type RequestProcessingState = 'requesting' | 'completing' | string +``` + +- `idle`: 空闲状态,没有正在进行的请求 +- `processing`: 正在处理中(包含 `requesting` 和 `completing` 两个子状态) +- `completed`: 请求已完成 +- `aborted`: 请求被中止 +- `error`: 请求发生错误 + +### 插件系统 + +`useMessage` 支持插件系统,可以通过插件扩展功能。 + +**默认激活的插件**:`fallbackRolePlugin`、`thinkingPlugin`、`lengthPlugin`(无需显式添加,已自动注入)。可通过插件的 `disabled` 参数禁用,例如 `thinkingPlugin({ disabled: true })`。 -enum STATUS { - INIT = 'init', // 初始状态 - PROCESSING = 'processing', // AI请求正在处理中, 还未响应,显示加载动画 - STREAMING = 'streaming', // 流式响应中分块数据返回中 - FINISHED = 'finished', // AI请求已完成 - ABORTED = 'aborted', // 用户中止请求 - ERROR = 'error', // AI请求发生错误 +**内置可选插件**:`toolPlugin`(工具调用,需添加到 `plugins` 数组中才会生效) + +可通过 `plugins` 选项追加或覆盖默认插件。插件提供了多个生命周期钩子: + +```typescript +interface UseMessagePlugin { + /** 插件名称 */ + name?: string + /** 是否禁用插件 */ + disabled?: boolean | ((context: BasePluginContext) => boolean) + /** 对话回合开始钩子 */ + onTurnStart?: (context: BasePluginContext) => MaybePromise + /** 对话回合结束钩子 */ + onTurnEnd?: (context: BasePluginContext) => MaybePromise + /** 请求开始前钩子 */ + onBeforeRequest?: ( + context: BasePluginContext & { + requestBody: MessageRequestBody + }, + ) => MaybePromise + /** 请求完成后钩子 */ + onAfterRequest?: ( + context: BasePluginContext & { + currentMessage: ChatMessage + lastChoice?: CompletionChoice + appendMessage: (message: ChatMessage | ChatMessage[]) => void + requestNext: () => void + }, + ) => MaybePromise + /** 数据块处理钩子 */ + onCompletionChunk?: ( + context: BasePluginContext & { + currentMessage: ChatMessage + choice?: CompletionChoice + chunk: ChatCompletion + }, + ) => void + /** 错误处理钩子 */ + onError?: (context: BasePluginContext & { error: unknown }) => void + /** 最终清理钩子 */ + onFinally?: (context: BasePluginContext) => void } +``` + +### 内置插件 + +#### fallbackRolePlugin + +在请求前为 `role` 为空的消息补全角色,默认使用 `assistant`。可用于兜底上游未设置 role 的消息。**已默认激活**;若需自定义配置,可显式传入覆盖: + +```typescript +import { fallbackRolePlugin, useMessage } from '@opentiny/tiny-robot-kit' + +useMessage({ + responseProvider, + plugins: [ + fallbackRolePlugin({ fallbackRole: 'assistant' }), // 可选,默认即为 'assistant' + ], +}) +``` + +#### lengthPlugin + +当模型返回 `finish_reason === 'length'`(达到 max_tokens 或上下文限制)时,自动追加一条 user 消息(如 "Please continue with your previous answer.")并调用 `requestNext()` 继续请求,实现“自动续写”。**已默认激活**;若需自定义配置,可显式传入覆盖: + +```typescript +import { lengthPlugin, useMessage } from '@opentiny/tiny-robot-kit' + +useMessage({ + responseProvider, + plugins: [ + lengthPlugin({ + continueContent: 'Please continue with your previous answer.', // 可选,默认即为此句 + }), + ], +}) +``` + +#### thinkingPlugin + +根据流式响应中的 `reasoning_content`(或 `choice.delta.reasoning_content`)更新当前消息的 `state.thinking`,用于展示“思考中”等 UI;在回合结束时清除该状态。**已默认激活**;若需禁用或自定义配置,可显式传入覆盖: + +```typescript +import { thinkingPlugin, useMessage } from '@opentiny/tiny-robot-kit' + +useMessage({ + responseProvider, + plugins: [thinkingPlugin({ /* 自定义选项 */ })], +}) +``` + +#### toolPlugin(工具调用) -// 状态常量 -const GeneratingStatus = [STATUS.PROCESSING, STATUS.STREAMING] -const FinalStatus = [STATUS.FINISHED, STATUS.ABORTED, STATUS.ERROR] +用于接入模型返回的 `tool_calls`:在请求前注入 `tools` 列表,在请求完成后解析 `tool_calls`、执行 `callTool`、追加 tool 消息并自动发起下一轮请求。支持取消/失败时补充或标记 tool 消息、下一轮是否排除 tool 消息等。**需显式添加到 `plugins` 数组才会生效**。 + +**必选参数:** + +- `getTools(): Promise` — 返回当前轮次要传给 API 的工具列表(OpenAI 格式)。 +- `callTool(toolCall, context): Promise> | AsyncGenerator<...>` — 执行单个工具调用,返回结果字符串或可流式返回的对象(会合并到对应 tool 消息的 content)。 + +**可选参数:** + +| 参数 | 类型 | 说明 | +| ----------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `beforeCallTools` | `(toolCalls, context) => Promise` | 在真正执行工具前调用,可用于校验或打点。 | +| `onToolCallStart` | `(toolCall, context) => void` | 单个工具开始执行时回调。 | +| `onToolCallEnd` | `(toolCall, context) => void` | 单个工具结束时的回调;`context.status` 为 `'success' \| 'failed' \| 'cancelled'`,失败时可有 `context.error`。 | +| `toolCallCancelledContent` | `string` | 请求被中止时,为未执行的 tool 消息填充的内容,默认 `'Tool call cancelled.'`。 | +| `toolCallFailedContent` | `string` | 工具执行抛错时,为对应 tool 消息填充的内容,默认 `'Tool call failed.'`。 | +| `autoFillMissingToolMessages` | `boolean` | 请求被中止时是否自动补全缺失的 tool 消息(用 `toolCallCancelledContent`),默认 `false`。 | +| `excludeToolMessagesNextTurn` | `boolean \| 'remove'` | 下一轮请求是否排除带 tool_calls 的 assistant 消息及对应 tool 消息:`true` 仅不发送,`'remove'` 从列表中移除,默认 `false`。 | + +```typescript +import { toolPlugin, useMessage } from '@opentiny/tiny-robot-kit' + +useMessage({ + responseProvider, + plugins: [ + toolPlugin({ + getTools: async () => [ + { + type: 'function', + function: { + name: 'get_weather', + description: 'Get weather by city name.', + parameters: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + }, + }, + }, + ], + callTool: async (toolCall, context) => { + const args = JSON.parse(toolCall.function?.arguments || '{}') + return `Weather of ${args.city}: Sunny.` + }, + onToolCallStart: (toolCall) => console.log('Tool start:', toolCall.function?.name), + onToolCallEnd: (toolCall, { status }) => console.log('Tool end:', status), + toolCallCancelledContent: 'Tool call cancelled.', + toolCallFailedContent: 'Tool call failed.', + }), + ], +}) ``` + +工具调用示例(含 `toolPlugin` 的完整对话流程)见上方示例中的「工具调用」。 diff --git a/docs/src/tools/utils.md b/docs/src/tools/utils.md new file mode 100644 index 000000000..fe49e5fe0 --- /dev/null +++ b/docs/src/tools/utils.md @@ -0,0 +1,103 @@ +--- +outline: [1, 3] +--- + +# 工具函数 Utils + +:::danger 重大版本升级 v0.4 +useMessage 在 v0.4 有重大变更。**从 v0.3.x 升级?** 请查看 [useMessage 迁移](../migration/use-message-migration)。 +::: + +工具函数模块提供了一些实用的辅助函数,用于处理流式响应。 + +## API + +### sseStreamToGenerator + +将 SSE 流转换为异步生成器。 + +```typescript +async function* sseStreamToGenerator( + response: Response, + options: { signal?: AbortSignal } = {} +): AsyncGenerator +``` + +#### 参数 + +- `response`: `Response` - fetch 响应对象 +- `options`: `{ signal?: AbortSignal }` - 配置选项 + - `signal`: `AbortSignal` - 可选的取消信号,用于中断流处理 + +#### 返回值 + +返回一个异步生成器,产出类型为 `T` 的数据。 + +#### 说明 + +- 当取消信号被触发时,会抛出 `name` 为 `'AbortError'` 的错误 +- 自动处理 SSE 格式的数据流,解析 `data:` 前缀的数据 +- 当遇到 `[DONE]` 标记时,生成器会结束 + +--- + +### formatMessages + +将各种格式的消息转换为标准的 `ChatMessage` 格式。 + +```typescript +function formatMessages(messages: Array): ChatMessage[] +``` + +#### 参数 + +- `messages`: `Array` - 消息数组,支持标准 `ChatMessage` 对象或字符串(字符串将作为 user 消息) + +#### 返回值 + +返回标准格式的 `ChatMessage[]`。 + +--- + +### extractTextFromResponse + +从聊天完成响应中提取文本内容。 + +```typescript +function extractTextFromResponse(response: ChatCompletionResponse): string +``` + +#### 参数 + +- `response`: `ChatCompletionResponse` - 聊天完成响应对象 + +#### 返回值 + +返回 `choices[0].message.content` 的文本内容,若无则返回空字符串。 + +--- + +### handleSSEStream + +通过回调处理器处理 SSE 流式响应。 + +```typescript +function handleSSEStream( + response: Response, + handler: StreamHandler, + signal?: AbortSignal +): Promise +``` + +#### 参数 + +- `response`: `Response` - fetch 响应对象 +- `handler`: `StreamHandler` - 流处理器 + - `onData`: `(data: ChatCompletionStreamResponse) => void` - 收到数据块时调用 + - `onError`: `(error: AIAdapterError) => void` - 发生错误时调用 + - `onDone`: `(finishReason?: string) => void` - 流结束时调用 +- `signal`: `AbortSignal` - 可选的取消信号 + +#### 返回值 + +返回 `Promise`,流处理完成后 resolve。 diff --git a/packages/components/src/bubble/Bubble.vue b/packages/components/src/bubble/Bubble.vue index fb5432961..ac09e2f49 100644 --- a/packages/components/src/bubble/Bubble.vue +++ b/packages/components/src/bubble/Bubble.vue @@ -1,26 +1,31 @@