-
-
+
+
+
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 @@
+
+
+
+ 通过 BubbleProvider 配置渲染器,包含 "🎯" 或 "VIP" 的消息会使用自定义渲染器(Box 透明且无 padding)。
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
-
+
使用 Markdown 渲染器渲染运行时组件(WebComponent)
+
+
+
-
-
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"
>
-
+
@@ -27,9 +27,9 @@
/>
@@ -149,7 +149,7 @@ import {
TrWelcome,
vDropzone,
} from '@opentiny/tiny-robot'
-import { AIClient, Conversation, GeneratingStatus, useConversation } from '@opentiny/tiny-robot-kit'
+import { ConversationInfo, sseStreamToGenerator, useConversation } from '@opentiny/tiny-robot-kit'
import {
IconAi,
IconClose,
@@ -162,14 +162,7 @@ import {
IconUser,
} from '@opentiny/tiny-robot-svgs'
import { TinySwitch } from '@opentiny/vue'
-import { type CSSProperties, h, markRaw, nextTick, onMounted, ref, watch } from 'vue'
-
-const client = new AIClient({
- provider: 'openai',
- // apiKey: 'your-api-key',
- defaultModel: 'gpt-3.5-turbo',
- apiUrl: window.parent?.location.origin || location.origin + import.meta.env.BASE_URL,
-})
+import { computed, type CSSProperties, h, markRaw, nextTick, onMounted, ref, watch } from 'vue'
const fullscreen = ref(false)
const show = ref(true)
@@ -440,31 +433,52 @@ const pillItems = [
},
]
-const { messageManager, state, createConversation, updateTitle, switchConversation, deleteConversation } =
- useConversation({
- client,
- events: {
- onReceiveData: (data, _messages, _preventDefault) => {
- // 执行 preventDefault 可以阻止默认写入消息列表的逻辑
- // preventDefault()
- console.log(data)
- },
- onLoaded: (conversations) => {
- console.log(conversations)
- },
+const {
+ activeConversation,
+ activeConversationId,
+ conversations,
+ createConversation,
+ switchConversation,
+ deleteConversation,
+ updateConversationTitle,
+ abortActiveRequest,
+} = useConversation({
+ useMessageOptions: {
+ responseProvider: async (requestBody, abortSignal) => {
+ const response = await fetch('/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 })
},
- })
+ },
+})
+
+const historyData = computed(() =>
+ conversations.value.map((item) => ({
+ ...item,
+ title: item.title || '',
+ })),
+)
-const { messages, messageState, inputMessage, sendMessage: _sendMessage, abortRequest } = messageManager
+const messageEngine = computed(() => activeConversation.value?.engine)
+const messages = computed(() => messageEngine.value?.messages.value || [])
+const isProcessing = computed(() => messageEngine.value?.isProcessing.value)
-const sendMessage = (...args: Parameters) => {
- if (!state.currentId) {
- createConversation()
+const sendMessage = (content: string) => {
+ if (!activeConversationId.value) {
+ createConversation({ title: content.slice(0, 10) })
}
- _sendMessage(...args)
+ messageEngine.value?.sendMessage(content)
}
const handlePromptItemClick = (ev: unknown, item: { description?: string }) => {
+ if (!item.description) return
sendMessage(item.description)
}
@@ -481,22 +495,23 @@ const roles: Record = {
const showHistory = ref(false)
-const handleHistoryTitleChange = (newTitle: string, item: Conversation) => {
- updateTitle(item.id!, newTitle)
+const handleHistoryTitleChange = (newTitle: string, item: ConversationInfo) => {
+ updateConversationTitle(item.id, newTitle)
}
-const handleHistorySelect = (item: Conversation) => {
+const handleHistorySelect = (item: ConversationInfo) => {
switchConversation(item.id)
showHistory.value = false
}
-const handleHistoryAction = (action: HistoryMenuItem, item: Conversation) => {
+const handleHistoryAction = (action: HistoryMenuItem, item: ConversationInfo) => {
if (action.id === 'delete') {
deleteConversation(item.id)
}
}
const senderRef = ref | null>(null)
+const inputMessage = ref('')
const currentTemplate = ref([])
const suggestionOpen = ref(false)
diff --git a/docs/demos/tools/conversation/Basic.vue b/docs/demos/tools/conversation/Basic.vue
index 3d2103598..5dae8db35 100644
--- a/docs/demos/tools/conversation/Basic.vue
+++ b/docs/demos/tools/conversation/Basic.vue
@@ -1,73 +1,72 @@
- 会话
-
-
-
切换会话
-
创建新对话
+
+
+
+
+ 切换会话
+
+ 创建新对话
+
+ 删除当前会话
+
+
diff --git a/docs/demos/tools/conversation/IndexedDB.vue b/docs/demos/tools/conversation/IndexedDB.vue
index be2e6dc6e..6c4bb344f 100644
--- a/docs/demos/tools/conversation/IndexedDB.vue
+++ b/docs/demos/tools/conversation/IndexedDB.vue
@@ -5,16 +5,20 @@
切换会话
-
+
创建新对话
清空存储
@@ -22,11 +26,11 @@
+
+
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 @@
+
+
+
+ 使用插件的 onError 处理错误;输入「error-renderer」通过 BubbleProvider 的 error 渲染器展示不同 UI。
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
模拟 responseProvider:不依赖真实 API,用于开发时模拟流式响应。
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ 用 requestState 和 processingState 驱动 UI。processingState 是
+ requestState 为 processing 时的子状态。
+
+
+ requestState:
+ {{ requestState }}
+ processingState:
+ {{ processingState ?? '—' }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+ 使用 toolPlugin 做工具调用:getTools + callTool。本示例使用模拟 API 返回
+ tool_calls。
+
+
+
+
+
+
+
+
+
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
+
-
-
-
+
+ {{ message.content }}
+
-
```
-3.**Vue 组件**:
+**Box 渲染器示例**
-content 对象中的所有属性都将传递给组件,onXXX会当作事件传递给组件,非props属性会当作attrs传递给组件
+Box 渲染器接收 `BubbleBoxRendererProps` 作为 props,包含 `placement` 和 `shape`,并通过插槽渲染内容。
```vue
+
+
- {{ props.content }}
+
+
+
```
-目前内置直接可用的的渲染器类型有
+**配置自定义渲染器**
-- `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
+
+
+
这是自定义 content 渲染器
+
{{ props.message.content }}
+
+
-### 隐藏角色
+
+```
-## 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
+
+
+
+
+ (User stopped)
+
+
+
+
+
+
+```
+
+### 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
+
+
+
+ id: {{ bubbleProps.id }}
+
+
+```
+
+```vue
+
+
+
+ id: {{ messages[0]?.id }}
+
+
+```
+
+### 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 @@
-
+
{
:class="$style['tr-bubble__avatar']"
/>
-
+
+
{
:content-index="index"
>
@@ -93,21 +119,13 @@ const shouldSplit = computed(() => {
-
-
-
-
-
-
+
@@ -142,11 +160,14 @@ const shouldSplit = computed(() => {
align-items: flex-start;
gap: 8px;
width: 100%;
+ user-select: none;
}
.tr-bubble__box {
max-width: var(--tr-bubble-max-width);
+ min-width: var(--tr-bubble-min-width);
width: fit-content;
+ user-select: text;
}
.tr-bubble__after {
diff --git a/packages/components/src/bubble/BubbleContentWrapper.vue b/packages/components/src/bubble/BubbleContentWrapper.vue
index a5a5437c2..06c9a4d64 100644
--- a/packages/components/src/bubble/BubbleContentWrapper.vue
+++ b/packages/components/src/bubble/BubbleContentWrapper.vue
@@ -7,7 +7,7 @@ const props = defineProps()
const renderer = useBubbleContentRenderer(() => props.message, props.contentIndex)
const emit = defineEmits<{
- (e: 'state-change', payload: { key: string; value: unknown; contentIndex?: number }): void
+ (e: 'state-change', payload: { key: string; value: unknown; contentIndex: number }): void
}>()
const handleStateChange = (key: string, value: unknown) => {
diff --git a/packages/components/src/bubble/BubbleItem.vue b/packages/components/src/bubble/BubbleItem.vue
index 5ce685d34..5ad660966 100644
--- a/packages/components/src/bubble/BubbleItem.vue
+++ b/packages/components/src/bubble/BubbleItem.vue
@@ -7,12 +7,13 @@ const props = defineProps<{
messageGroup: BubbleMessageGroup
roleConfig?: BubbleRoleConfig
contentRenderMode?: BubbleProps['contentRenderMode']
+ contentResolver?: BubbleProps['contentResolver']
}>()
defineSlots()
const emit = defineEmits<{
- (e: 'state-change', payload: { key: string; value: unknown; messageIndex: number; contentIndex?: number }): void
+ (e: 'state-change', payload: { key: string; value: unknown; messageIndex: number; contentIndex: number }): void
}>()
// Provide messages for each BubbleItem instance
@@ -24,6 +25,7 @@ setupBubbleMessageGroup(() => props.messageGroup)
v-bind="roleConfig"
:role="messageGroup.role"
:content-render-mode="contentRenderMode"
+ :content-resolver="contentResolver"
@state-change="emit('state-change', $event)"
>
diff --git a/packages/components/src/bubble/BubbleList.vue b/packages/components/src/bubble/BubbleList.vue
index 689e93fe4..63302fa47 100644
--- a/packages/components/src/bubble/BubbleList.vue
+++ b/packages/components/src/bubble/BubbleList.vue
@@ -1,25 +1,42 @@
+
+
+
+
+
+
diff --git a/packages/components/src/bubble/renderers/Loading.vue b/packages/components/src/bubble/renderers/Loading.vue
new file mode 100644
index 000000000..58e0e063d
--- /dev/null
+++ b/packages/components/src/bubble/renderers/Loading.vue
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/src/bubble/renderers/Markdown.vue b/packages/components/src/bubble/renderers/Markdown.vue
new file mode 100644
index 000000000..ef5b0246c
--- /dev/null
+++ b/packages/components/src/bubble/renderers/Markdown.vue
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
diff --git a/packages/components/src/bubble/renderers/Reasoning.vue b/packages/components/src/bubble/renderers/Reasoning.vue
new file mode 100644
index 000000000..0482df3e0
--- /dev/null
+++ b/packages/components/src/bubble/renderers/Reasoning.vue
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
{{ props.message.reasoning_content }}
+
+
+
+
+
+
diff --git a/packages/components/src/bubble/renderers/Text.vue b/packages/components/src/bubble/renderers/Text.vue
index 7714640a0..aaa737ab0 100644
--- a/packages/components/src/bubble/renderers/Text.vue
+++ b/packages/components/src/bubble/renderers/Text.vue
@@ -4,12 +4,12 @@ import { BubbleContentRendererProps } from '../index.type'
const props = defineProps()
-const content = useMessageContent(() => props.message, props.contentIndex)
+const { contentText: content } = useMessageContent(props)
- {{ typeof content === 'string' ? content : content?.text }}
+ {{ content }}
diff --git a/packages/components/src/bubble/renderers/Tool.vue b/packages/components/src/bubble/renderers/Tool.vue
new file mode 100644
index 000000000..cf3c99fef
--- /dev/null
+++ b/packages/components/src/bubble/renderers/Tool.vue
@@ -0,0 +1,255 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/components/src/bubble/renderers/ToolRole.vue b/packages/components/src/bubble/renderers/ToolRole.vue
new file mode 100644
index 000000000..4e27f4fae
--- /dev/null
+++ b/packages/components/src/bubble/renderers/ToolRole.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/packages/components/src/bubble/renderers/Tools.vue b/packages/components/src/bubble/renderers/Tools.vue
new file mode 100644
index 000000000..df27380b7
--- /dev/null
+++ b/packages/components/src/bubble/renderers/Tools.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/packages/components/src/bubble/renderers/allRenderers.ts b/packages/components/src/bubble/renderers/allRenderers.ts
index cd9d2e730..d5e1199f9 100644
--- a/packages/components/src/bubble/renderers/allRenderers.ts
+++ b/packages/components/src/bubble/renderers/allRenderers.ts
@@ -1,7 +1,21 @@
import Box from './Box.vue'
+import Image from './Image.vue'
+import Loading from './Loading.vue'
+import Markdown from './Markdown.vue'
+import Reasoning from './Reasoning.vue'
import Text from './Text.vue'
+import Tool from './Tool.vue'
+import ToolRole from './ToolRole.vue'
+import Tools from './Tools.vue'
export const BubbleRenderers = {
Box,
+ Image,
+ Loading,
+ Markdown,
+ Reasoning,
Text,
+ Tool,
+ ToolRole,
+ Tools,
}
diff --git a/packages/components/src/bubble/renderers/defaultRenderers.ts b/packages/components/src/bubble/renderers/defaultRenderers.ts
index adce5b609..0e37ade0c 100644
--- a/packages/components/src/bubble/renderers/defaultRenderers.ts
+++ b/packages/components/src/bubble/renderers/defaultRenderers.ts
@@ -1,11 +1,50 @@
import { markRaw } from 'vue'
+import { BubbleRendererMatchPriority } from '../constants'
import type { BubbleBoxRendererMatch, BubbleContentRendererMatch } from '../index.type'
import Box from './Box.vue'
+import Image from './Image.vue'
+import Loading from './Loading.vue'
+import Reasoning from './Reasoning.vue'
import Text from './Text.vue'
+import ToolRole from './ToolRole.vue'
+import Tools from './Tools.vue'
-export const defaultBoxRendererMatches: Array = []
+export const defaultBoxRendererMatches: Array = [
+ {
+ find: (_, content) => content?.type === 'image_url',
+ renderer: markRaw(Box),
+ priority: BubbleRendererMatchPriority.NORMAL,
+ attributes: { 'data-box-type': 'image' },
+ },
+]
-export const defaultContentRendererMatches: Array = []
+export const defaultContentRendererMatches: Array = [
+ {
+ find: (message) => Boolean(message.loading),
+ renderer: markRaw(Loading),
+ priority: BubbleRendererMatchPriority.LOADING,
+ },
+ {
+ find: (message) => typeof message.reasoning_content === 'string',
+ renderer: markRaw(Reasoning),
+ priority: BubbleRendererMatchPriority.NORMAL,
+ },
+ {
+ find: (message) => Array.isArray(message.tool_calls) && message.tool_calls.length > 0,
+ renderer: markRaw(Tools),
+ priority: BubbleRendererMatchPriority.NORMAL,
+ },
+ {
+ find: (_, content) => content.type === 'image_url',
+ renderer: markRaw(Image),
+ priority: BubbleRendererMatchPriority.CONTENT,
+ },
+ {
+ find: (message) => message.role === 'tool',
+ renderer: markRaw(ToolRole),
+ priority: BubbleRendererMatchPriority.ROLE,
+ },
+]
export const defaultFallbackBoxRenderer = markRaw(Box)
export const defaultFallbackContentRenderer = markRaw(Text)
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
index 3234fa0a5..86734c520 100644
--- a/packages/components/src/index.ts
+++ b/packages/components/src/index.ts
@@ -45,6 +45,7 @@ export {
useBubbleStateChangeFn,
useMessageContent,
useOmitMessageFields,
+ useToolCall,
} from './bubble'
export { useTheme } from './theme-provider/useTheme'
export { vDropzone } from './drag-overlay/directives/vDropzone'
diff --git a/packages/components/src/shared/composables/useAutoScroll.ts b/packages/components/src/shared/composables/useAutoScroll.ts
index b762c6973..fe4873189 100644
--- a/packages/components/src/shared/composables/useAutoScroll.ts
+++ b/packages/components/src/shared/composables/useAutoScroll.ts
@@ -54,7 +54,7 @@ export function useAutoScroll(
const targetElement = () => unrefElement(target)
- const { y, isScrolling } = useScroll(targetElement, { throttle: scrollThrottle })
+ const { y, isScrolling, arrivedState } = useScroll(targetElement, { throttle: scrollThrottle })
/** 判断是否接近底部 */
const isNearBottom = (el: HTMLElement) => {
@@ -133,6 +133,7 @@ export function useAutoScroll(
return {
scrollToBottom,
+ arrivedState,
}
}
diff --git a/packages/components/src/styles/components/bubble.less b/packages/components/src/styles/components/bubble.less
index e90cb3905..7f77db90d 100644
--- a/packages/components/src/styles/components/bubble.less
+++ b/packages/components/src/styles/components/bubble.less
@@ -3,6 +3,7 @@
@vars: {
// Layout & sizing
+ min-width: auto;
max-width: 80%;
gap: 16px;
// Text
@@ -32,6 +33,7 @@
image-embedded-border-radius: 4px;
image-embedded-margin-block: 4px;
// Tool renderer
+ tool-call-bg: var(--tr-container-bg-default-2);
tool-call-space-y: 8px;
tool-call-min-width: unset;
tool-call-max-width: unset;
diff --git a/packages/kit/src/client.ts b/packages/kit/src/client.ts
index 9136b4783..b4d7fe111 100644
--- a/packages/kit/src/client.ts
+++ b/packages/kit/src/client.ts
@@ -8,6 +8,7 @@ import type { BaseModelProvider } from './providers/base'
import { OpenAIProvider } from './providers/openai'
/**
+ * @deprecated
* AI客户端类
*/
export class AIClient {
diff --git a/packages/kit/src/index.ts b/packages/kit/src/index.ts
index 4c7770b82..1456bf582 100644
--- a/packages/kit/src/index.ts
+++ b/packages/kit/src/index.ts
@@ -1,10 +1,7 @@
export { AIClient } from './client'
-
export { BaseModelProvider } from './providers/base'
export { OpenAIProvider } from './providers/openai'
-
-export { formatMessages, extractTextFromResponse, handleSSEStream } from './utils'
-
-export * from './vue'
-
+export * from './storage'
export * from './types'
+export { extractTextFromResponse, formatMessages, handleSSEStream, sseStreamToGenerator } from './utils'
+export * from './vue'
diff --git a/packages/kit/src/storage/indexedDBStrategy.ts b/packages/kit/src/storage/indexedDBStrategy.ts
index 777082cac..f9e39c9ce 100644
--- a/packages/kit/src/storage/indexedDBStrategy.ts
+++ b/packages/kit/src/storage/indexedDBStrategy.ts
@@ -1,6 +1,16 @@
import { openDB, type DBSchema, type IDBPDatabase } from 'idb'
-import type { Conversation } from '../vue/conversation/types'
+import type { ChatMessage } from '../types'
+import type { ConversationInfo } from '../vue/conversation/types'
import type { ConversationStorageStrategy } from './types'
+import { transformMessages, unwrapProxy } from './utils'
+
+/**
+ * 存储的消息结构(一个会话的所有消息)
+ */
+interface StoredMessages {
+ conversationId: string // 会话 ID(作为主键)
+ messages: ChatMessage[] // 该会话的所有消息数组
+}
/**
* IndexedDB 数据库结构定义
@@ -8,11 +18,15 @@ import type { ConversationStorageStrategy } from './types'
interface ConversationDB extends DBSchema {
conversations: {
key: string // conversation.id
- value: Conversation
+ value: ConversationInfo
indexes: {
'by-updated': number // 按更新时间索引
}
}
+ messages: {
+ key: string // conversationId
+ value: StoredMessages
+ }
}
/**
@@ -23,7 +37,7 @@ export class IndexedDBStrategy implements ConversationStorageStrategy {
private dbVersion: number
private db: IDBPDatabase | null = null
- constructor(dbName: string = 'tiny-robot-ai-db', dbVersion: number = 1) {
+ constructor(dbName: string = 'tiny-robot-ai-db', dbVersion: number = 3) {
this.dbName = dbName
this.dbVersion = dbVersion
}
@@ -35,10 +49,17 @@ export class IndexedDBStrategy implements ConversationStorageStrategy {
if (!this.db) {
this.db = await openDB(this.dbName, this.dbVersion, {
upgrade(db) {
- // 创建对象存储和索引
+ // 创建会话对象存储和索引
if (!db.objectStoreNames.contains('conversations')) {
- const store = db.createObjectStore('conversations', { keyPath: 'id' })
- store.createIndex('by-updated', 'updatedAt')
+ const conversationStore = db.createObjectStore('conversations', { keyPath: 'id' })
+ conversationStore.createIndex('by-updated', 'updatedAt')
+ }
+
+ // 创建消息对象存储(使用 conversationId 作为主键)
+ if (!db.objectStoreNames.contains('messages')) {
+ db.createObjectStore('messages', {
+ keyPath: 'conversationId',
+ })
}
},
})
@@ -46,45 +67,96 @@ export class IndexedDBStrategy implements ConversationStorageStrategy {
return this.db
}
- async saveConversations(conversations: Conversation[]): Promise {
+ /**
+ * 加载所有会话(只包含元数据)
+ */
+ async loadConversations(): Promise {
+ try {
+ const db = await this.getDB()
+
+ // 按更新时间倒序获取所有会话
+ const conversations = await db.getAllFromIndex('conversations', 'by-updated')
+
+ // 最新的在前
+ return conversations.reverse()
+ } catch (error) {
+ console.error('加载会话失败:', error)
+ return []
+ }
+ }
+
+ /**
+ * 加载指定会话的所有消息
+ */
+ async loadMessages(conversationId: string): Promise {
try {
const db = await this.getDB()
- const tx = db.transaction('conversations', 'readwrite')
- // 清空现有数据
- await tx.store.clear()
+ // 通过 conversationId 直接获取该会话的消息记录
+ const storedMessages = await db.get('messages', conversationId)
+
+ if (!storedMessages) {
+ return []
+ }
- // 批量插入
- await Promise.all(conversations.map((conv) => tx.store.put(conv)))
+ // 转换消息格式
+ return transformMessages(storedMessages.messages)
+ } catch (error) {
+ console.error('加载会话消息失败:', error)
+ return []
+ }
+ }
- await tx.done
+ /**
+ * 保存或更新会话元数据
+ */
+ async saveConversation(conversation: ConversationInfo): Promise {
+ try {
+ const db = await this.getDB()
+ // 解包 Proxy 对象,确保数据可序列化
+ const serializableConversation = unwrapProxy(conversation)
+ await db.put('conversations', serializableConversation)
} catch (error) {
console.error('保存会话失败:', error)
throw error
}
}
- async loadConversations(): Promise {
+ /**
+ * 保存指定会话的消息
+ */
+ async saveMessages(conversationId: string, messages: ChatMessage[]): Promise {
try {
const db = await this.getDB()
- // 按更新时间倒序获取所有会话
- const conversations = await db.getAllFromIndex('conversations', 'by-updated')
+ // 递归解包 Proxy 对象,确保消息数据可序列化
+ const serializableMessages = unwrapProxy(messages)
- // 最新的在前
- return conversations.reverse()
+ // 直接保存或更新该会话的消息记录(使用 put 方法,如果存在则更新,不存在则创建)
+ await db.put('messages', {
+ conversationId,
+ messages: serializableMessages,
+ })
} catch (error) {
- console.error('加载会话失败:', error)
- return []
+ console.error('保存会话消息失败:', error)
+ throw error
}
}
- async clear(): Promise {
+ /**
+ * 删除会话及其所有消息
+ */
+ async deleteConversation(conversationId: string): Promise {
try {
const db = await this.getDB()
- await db.clear('conversations')
+
+ // 删除会话
+ await db.delete('conversations', conversationId)
+
+ // 删除该会话的消息记录(通过 conversationId 直接删除)
+ await db.delete('messages', conversationId)
} catch (error) {
- console.error('清空会话失败:', error)
+ console.error('删除会话失败:', error)
throw error
}
}
diff --git a/packages/kit/src/storage/localStorageStrategy.ts b/packages/kit/src/storage/localStorageStrategy.ts
index de80c9d6b..66c9929fc 100644
--- a/packages/kit/src/storage/localStorageStrategy.ts
+++ b/packages/kit/src/storage/localStorageStrategy.ts
@@ -1,5 +1,13 @@
-import type { Conversation } from '../vue/conversation/types'
+import { ChatMessage } from '../types'
+import type { ConversationInfo } from '../vue/conversation/types'
import type { ConversationStorageStrategy } from './types'
+import { transformMessages } from './utils'
+
+const getConversations = (storageKey: string) => {
+ const conversationsStr = localStorage.getItem(storageKey)
+ const conversations = conversationsStr ? JSON.parse(conversationsStr) : []
+ return conversations as (ConversationInfo & { messages: ChatMessage[] })[]
+}
/**
* 本地存储策略
@@ -11,29 +19,68 @@ export class LocalStorageStrategy implements ConversationStorageStrategy {
this.storageKey = storageKey
}
- saveConversations(conversations: Conversation[]): void {
+ saveConversation(conversation: ConversationInfo) {
try {
+ const conversations = getConversations(this.storageKey)
+ const index = conversations.findIndex((item) => item.id === conversation.id)
+ if (index !== -1) {
+ Object.assign(conversations[index], conversation)
+ } else {
+ conversations.unshift({ ...conversation, messages: [] })
+ }
localStorage.setItem(this.storageKey, JSON.stringify(conversations))
} catch (error) {
console.error('保存会话失败:', error)
}
}
- loadConversations(): Conversation[] {
+ loadConversations(): ConversationInfo[] {
try {
- const data = localStorage.getItem(this.storageKey)
- return data ? JSON.parse(data) : []
+ const conversations = getConversations(this.storageKey)
+ return conversations.map((conversation) => ({
+ id: conversation.id,
+ title: conversation.title,
+ createdAt: conversation.createdAt,
+ updatedAt: conversation.updatedAt,
+ metadata: conversation.metadata,
+ }))
} catch (error) {
console.error('加载会话失败:', error)
return []
}
}
- clear(): void {
+ saveMessages(conversationId: string, messages: ChatMessage[]) {
+ try {
+ const conversations = getConversations(this.storageKey)
+ const index = conversations.findIndex((item) => item.id === conversationId)
+ if (index !== -1) {
+ conversations[index].messages = messages
+ }
+ localStorage.setItem(this.storageKey, JSON.stringify(conversations))
+ } catch (error) {
+ console.error('保存会话消息失败:', error)
+ }
+ }
+
+ loadMessages(conversationId: string) {
try {
- localStorage.removeItem(this.storageKey)
+ const conversations = getConversations(this.storageKey)
+ const conversation = conversations.find((item) => item.id === conversationId)
+ const messages = transformMessages(conversation?.messages || [])
+ return messages
} catch (error) {
- console.error('清空会话失败:', error)
+ console.error('加载会话消息失败:', error)
+ return []
+ }
+ }
+
+ deleteConversation(conversationId: string) {
+ const conversations = getConversations(this.storageKey)
+ const index = conversations.findIndex((item) => item.id === conversationId)
+ if (index !== -1) {
+ conversations.splice(index, 1)
}
+ localStorage.setItem(this.storageKey, JSON.stringify(conversations))
}
}
diff --git a/packages/kit/src/storage/types.ts b/packages/kit/src/storage/types.ts
index 1d6400664..31b48c9ad 100644
--- a/packages/kit/src/storage/types.ts
+++ b/packages/kit/src/storage/types.ts
@@ -1,13 +1,28 @@
-import type { Conversation } from '../vue/conversation/types'
+import { ChatMessage, MaybePromise } from '../types'
+import type { ConversationInfo } from '../vue/conversation/types'
/**
* 存储策略接口
*/
export interface ConversationStorageStrategy {
- /** 保存会话列表 */
- saveConversations: (conversations: Conversation[]) => Promise | void
- /** 加载会话列表 */
- loadConversations: () => Promise | Conversation[]
- /** 清空所有会话(可选) */
- clear?: () => Promise | void
+ /**
+ * Load all conversations (id and title only).
+ */
+ loadConversations: () => MaybePromise
+ /**
+ * Load all messages for a given conversation.
+ */
+ loadMessages: (conversationId: string) => MaybePromise
+ /**
+ * Persist conversation metadata (create or update).
+ */
+ saveConversation: (conversation: ConversationInfo) => MaybePromise
+ /**
+ * Persist messages for a given conversation.
+ */
+ saveMessages: (conversationId: string, messages: ChatMessage[]) => MaybePromise
+ /**
+ * Optional method to delete a conversation and its messages.
+ */
+ deleteConversation?: (conversationId: string) => MaybePromise
}
diff --git a/packages/kit/src/storage/utils.ts b/packages/kit/src/storage/utils.ts
new file mode 100644
index 000000000..673cecf04
--- /dev/null
+++ b/packages/kit/src/storage/utils.ts
@@ -0,0 +1,121 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { toRaw } from 'vue'
+import { ChatMessage } from '../types'
+
+/**
+ * 递归解包 Proxy 对象,将 Vue 响应式对象转换为普通对象
+ * 同时移除不可序列化的内容(函数、Symbol 等)
+ *
+ * @param value - 要解包的值
+ * @param visited - 用于检测循环引用和共享引用的 WeakMap,映射原始对象到其克隆对象
+ * @returns 解包后的普通对象
+ */
+export function unwrapProxy(value: T, visited: WeakMap