@veltra/ai 2.1.1 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/chat/transports/openai.js +18 -17
  2. package/dist/chat/transports/openai.js.map +1 -1
  3. package/dist/components/ai-chat/ai-chat.js +1 -1
  4. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  5. package/dist/components/ai-chat/message-item.js +1 -1
  6. package/dist/components/ai-chat/message-item.js.map +1 -1
  7. package/dist/components/ai-chat/message-list.js +164 -65
  8. package/dist/components/ai-chat/message-list.js.map +1 -1
  9. package/dist/components/ai-chat/style2.css +101 -21
  10. package/dist/components/ai-chat/tool-call.js +2 -6
  11. package/dist/components/ai-chat/tool-call.js.map +1 -1
  12. package/dist/components/ai-chat/turn-process.js +56 -0
  13. package/dist/components/ai-chat/turn-process.js.map +1 -0
  14. package/dist/components/ai-orb/orb-renderer.js +88 -8
  15. package/dist/components/ai-orb/orb-renderer.js.map +1 -1
  16. package/dist/types/ai-chat.d.ts +1 -1
  17. package/package.json +15 -6
  18. package/src/components/ai-chat/__test__/ai-chat.test.ts +136 -35
  19. package/src/components/ai-chat/ai-chat.vue +2 -2
  20. package/src/components/ai-chat/message-item.vue +3 -2
  21. package/src/components/ai-chat/message-list.vue +186 -66
  22. package/src/components/ai-chat/style.scss +130 -27
  23. package/src/components/ai-chat/tool-call.vue +1 -10
  24. package/src/components/ai-chat/turn-process.vue +47 -0
  25. package/src/components/ai-orb/__test__/ai-orb.test.ts +28 -3
  26. package/src/components/ai-orb/orb-renderer.ts +113 -5
  27. package/src/tools/ask-question/__test__/ask-question.test.ts +13 -3
  28. package/src/types/ai-chat.ts +1 -1
@@ -5,25 +5,26 @@ function toOpenAIMessages(messages, systemPrompt) {
5
5
  role: "system",
6
6
  content: systemPrompt
7
7
  });
8
- for (const msg of messages) if (msg.role === "user") if (msg.attachments?.length) {
9
- const parts = [];
10
- if (msg.content) parts.push({
11
- type: "text",
12
- text: msg.content
13
- });
14
- for (const att of msg.attachments) parts.push({
15
- type: "image_url",
16
- image_url: { url: att.dataUrl }
17
- });
18
- result.push({
8
+ for (const msg of messages) if (msg.role === "user") {
9
+ if (msg.attachments?.length) {
10
+ const parts = [];
11
+ if (msg.content) parts.push({
12
+ type: "text",
13
+ text: msg.content
14
+ });
15
+ for (const att of msg.attachments) parts.push({
16
+ type: "image_url",
17
+ image_url: { url: att.dataUrl }
18
+ });
19
+ result.push({
20
+ role: "user",
21
+ content: parts
22
+ });
23
+ } else result.push({
19
24
  role: "user",
20
- content: parts
25
+ content: msg.content
21
26
  });
22
- } else result.push({
23
- role: "user",
24
- content: msg.content
25
- });
26
- else if (msg.role === "assistant") {
27
+ } else if (msg.role === "assistant") {
27
28
  const item = {
28
29
  role: "assistant",
29
30
  content: msg.content || ""
@@ -1 +1 @@
1
- {"version":3,"file":"openai.js","names":[],"sources":["../../../src/chat/transports/openai.ts"],"sourcesContent":["import type { ChatModelOption, ChatProvider } from '../../providers'\nimport type {\n ChatMessage,\n ChatTool,\n ChatTransport,\n ChatTransportHandlers,\n ChatTransportRequest\n} from '../types'\n\n/** createOpenAITransport 的配置项(多 Provider) */\nexport interface OpenAITransportOptions {\n /** 至少一个 Provider;模型 id 须跨 Provider 全局唯一 */\n providers: ChatProvider[]\n /** 全局额外请求头(与各 Provider headers 合并,Provider 优先) */\n headers?: Record<string, string>\n /** 全局额外请求体字段,如 temperature、top_p */\n body?: Record<string, unknown>\n}\n\n/** 带扁平模型列表元数据的 OpenAI 兼容 transport */\nexport type OpenAITransport = ChatTransport & {\n /** 供 UI 使用的扁平模型列表 */\n readonly models: ChatModelOption[]\n /** 默认模型 id(首个 Provider 的首个模型) */\n readonly defaultModel: string\n}\n\ninterface OpenAIMessage {\n role: string\n content?: unknown\n tool_calls?: unknown[]\n tool_call_id?: string\n}\n\nfunction toOpenAIMessages(messages: ChatMessage[], systemPrompt?: string): OpenAIMessage[] {\n const result: OpenAIMessage[] = []\n\n if (systemPrompt) {\n result.push({ role: 'system', content: systemPrompt })\n }\n\n for (const msg of messages) {\n if (msg.role === 'user') {\n if (msg.attachments?.length) {\n const parts: unknown[] = []\n if (msg.content) parts.push({ type: 'text', text: msg.content })\n for (const att of msg.attachments) {\n parts.push({ type: 'image_url', image_url: { url: att.dataUrl } })\n }\n result.push({ role: 'user', content: parts })\n } else {\n result.push({ role: 'user', content: msg.content })\n }\n } else if (msg.role === 'assistant') {\n const item: OpenAIMessage = { role: 'assistant', content: msg.content || '' }\n if (msg.toolCalls?.length) {\n item.tool_calls = msg.toolCalls.map((call) => ({\n id: call.id,\n type: 'function',\n function: { name: call.name, arguments: call.arguments }\n }))\n }\n result.push(item)\n } else if (msg.role === 'tool') {\n result.push({ role: 'tool', tool_call_id: msg.toolCallId, content: msg.content })\n }\n }\n\n return result\n}\n\nfunction toOpenAITools(tools: ChatTool[]): unknown[] {\n return tools.map((tool) => ({\n type: 'function',\n function: { name: tool.name, description: tool.description, parameters: tool.parameters }\n }))\n}\n\ninterface AccumulatedToolCall {\n id: string\n name: string\n arguments: string\n}\n\n/**\n * 逐行解析 SSE 流。\n * 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。\n */\nasync function parseSSE(\n body: ReadableStream<Uint8Array>,\n signal: AbortSignal,\n handlers: ChatTransportHandlers\n): Promise<void> {\n const reader = body.getReader()\n const decoder = new TextDecoder()\n let buffer = ''\n\n // 按 OpenAI 流式协议中 tool_calls 的 index 累积参数片段\n const accumulated = new Map<number, AccumulatedToolCall>()\n let lastIndex = -1\n\n const flushToolCalls = () => {\n if (!handlers.onToolCall) return\n for (const call of accumulated.values()) {\n if (call.id && call.name) {\n handlers.onToolCall({ id: call.id, name: call.name, arguments: call.arguments })\n }\n }\n accumulated.clear()\n }\n\n const handleData = (data: string) => {\n if (data === '[DONE]') return\n\n let chunk: any\n try {\n chunk = JSON.parse(data)\n } catch {\n return\n }\n\n const delta = chunk.choices?.[0]?.delta\n if (!delta) return\n\n if (typeof delta.content === 'string' && delta.content) {\n handlers.onTextDelta(delta.content)\n }\n\n // 兼容 reasoning_content(DeepSeek)与 reasoning(部分 OpenAI 兼容端点)\n const reasoning = delta.reasoning_content ?? delta.reasoning\n if (typeof reasoning === 'string' && reasoning) {\n handlers.onReasoningDelta?.(reasoning)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n for (const tc of delta.tool_calls) {\n const index: number = tc.index ?? 0\n // 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去\n if (index > lastIndex && accumulated.size > 0) flushToolCalls()\n lastIndex = Math.max(lastIndex, index)\n\n let call = accumulated.get(index)\n if (!call) {\n call = { id: '', name: '', arguments: '' }\n accumulated.set(index, call)\n }\n if (tc.id) call.id = tc.id\n if (tc.function?.name) call.name += tc.function.name\n if (tc.function?.arguments) call.arguments += tc.function.arguments\n }\n }\n }\n\n try {\n // 递归泵取代替 while 循环,避免 await-in-loop\n const pump = async (): Promise<void> => {\n const { done, value } = await reader.read()\n if (done || signal.aborted) return\n\n buffer += decoder.decode(value, { stream: true })\n\n let newlineIndex: number\n while ((newlineIndex = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newlineIndex).trim()\n buffer = buffer.slice(newlineIndex + 1)\n if (line.startsWith('data:')) {\n handleData(line.slice(5).trim())\n }\n }\n\n return pump()\n }\n\n await pump()\n\n if (buffer.trim().startsWith('data:')) {\n handleData(buffer.trim().slice(5).trim())\n }\n } finally {\n reader.releaseLock()\n }\n\n flushToolCalls()\n}\n\n/** 校验 providers 并展开扁平模型列表 */\nfunction flattenModels(providers: ChatProvider[]): {\n models: ChatModelOption[]\n byModelId: Map<string, ChatProvider>\n} {\n if (!providers.length) {\n throw new Error('[createOpenAITransport] providers 不能为空')\n }\n\n const models: ChatModelOption[] = []\n const byModelId = new Map<string, ChatProvider>()\n\n for (const provider of providers) {\n if (!provider.models?.length) {\n throw new Error(`[createOpenAITransport] Provider \"${provider.id}\" 未配置 models`)\n }\n for (const model of provider.models) {\n if (byModelId.has(model.id)) {\n throw new Error(\n `[createOpenAITransport] 模型 id \"${model.id}\" 重复(跨 Provider 须全局唯一)`\n )\n }\n byModelId.set(model.id, provider)\n models.push({ ...model, providerId: provider.id, providerLabel: provider.label })\n }\n }\n\n return { models, byModelId }\n}\n\n/** 缺省:写入 OpenAI 兼容的 reasoning_effort */\nfunction defaultApplyReasoning(level: string, body: Record<string, unknown>) {\n body.reasoning_effort = level\n}\n\n/**\n * 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。\n * 按 request.model 路由到对应 Provider 的 endpoint;手写 fetch + SSE,无第三方依赖。\n */\nexport function createOpenAITransport(options: OpenAITransportOptions): OpenAITransport {\n const { providers, headers: globalHeaders, body: globalBody } = options\n const { models, byModelId } = flattenModels(providers)\n const defaultModel = models[0]!.id\n\n const send: ChatTransport = async (\n request: ChatTransportRequest,\n handlers: ChatTransportHandlers\n ) => {\n const { messages, systemPrompt, tools, signal, reasoningLevel } = request\n const modelId = request.model ?? defaultModel\n const provider = byModelId.get(modelId)\n\n if (!provider) {\n handlers.onError?.(new Error(`未找到模型 \"${modelId}\" 对应的 Provider`))\n return\n }\n\n const requestBody: Record<string, unknown> = {\n ...globalBody,\n model: modelId,\n stream: true,\n messages: toOpenAIMessages(messages, systemPrompt),\n ...(tools?.length ? { tools: toOpenAITools(tools) } : {})\n }\n\n if (reasoningLevel) {\n const apply = provider.applyReasoning ?? defaultApplyReasoning\n apply(reasoningLevel, requestBody)\n }\n\n let response: Response\n try {\n response = await fetch(provider.endpoint, {\n method: 'POST',\n signal,\n headers: {\n 'Content-Type': 'application/json',\n ...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}),\n ...globalHeaders,\n ...provider.headers\n },\n body: JSON.stringify(requestBody)\n })\n } catch (error) {\n if (!signal.aborted) {\n handlers.onError?.(error instanceof Error ? error : new Error(String(error)))\n }\n return\n }\n\n if (!response.ok) {\n const text = await response.text().catch(() => '')\n handlers.onError?.(\n new Error(`请求失败(${response.status}):${text || response.statusText}`)\n )\n return\n }\n\n if (!response.body) {\n handlers.onError?.(new Error('响应不包含可读取的流'))\n return\n }\n\n try {\n await parseSSE(response.body, signal, handlers)\n } catch (error) {\n if (!signal.aborted) {\n handlers.onError?.(error instanceof Error ? error : new Error(String(error)))\n }\n }\n }\n\n return Object.assign(send, { models, defaultModel })\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"openai.js","names":[],"sources":["../../../src/chat/transports/openai.ts"],"sourcesContent":["import type { ChatModelOption, ChatProvider } from '../../providers'\nimport type {\n ChatMessage,\n ChatTool,\n ChatTransport,\n ChatTransportHandlers,\n ChatTransportRequest\n} from '../types'\n\n/** createOpenAITransport 的配置项(多 Provider) */\nexport interface OpenAITransportOptions {\n /** 至少一个 Provider;模型 id 须跨 Provider 全局唯一 */\n providers: ChatProvider[]\n /** 全局额外请求头(与各 Provider headers 合并,Provider 优先) */\n headers?: Record<string, string>\n /** 全局额外请求体字段,如 temperature、top_p */\n body?: Record<string, unknown>\n}\n\n/** 带扁平模型列表元数据的 OpenAI 兼容 transport */\nexport type OpenAITransport = ChatTransport & {\n /** 供 UI 使用的扁平模型列表 */\n readonly models: ChatModelOption[]\n /** 默认模型 id(首个 Provider 的首个模型) */\n readonly defaultModel: string\n}\n\ninterface OpenAIMessage {\n role: string\n content?: unknown\n tool_calls?: unknown[]\n tool_call_id?: string\n}\n\nfunction toOpenAIMessages(messages: ChatMessage[], systemPrompt?: string): OpenAIMessage[] {\n const result: OpenAIMessage[] = []\n\n if (systemPrompt) {\n result.push({ role: 'system', content: systemPrompt })\n }\n\n for (const msg of messages) {\n if (msg.role === 'user') {\n if (msg.attachments?.length) {\n const parts: unknown[] = []\n if (msg.content) parts.push({ type: 'text', text: msg.content })\n for (const att of msg.attachments) {\n parts.push({ type: 'image_url', image_url: { url: att.dataUrl } })\n }\n result.push({ role: 'user', content: parts })\n } else {\n result.push({ role: 'user', content: msg.content })\n }\n } else if (msg.role === 'assistant') {\n const item: OpenAIMessage = { role: 'assistant', content: msg.content || '' }\n if (msg.toolCalls?.length) {\n item.tool_calls = msg.toolCalls.map((call) => ({\n id: call.id,\n type: 'function',\n function: { name: call.name, arguments: call.arguments }\n }))\n }\n result.push(item)\n } else if (msg.role === 'tool') {\n result.push({ role: 'tool', tool_call_id: msg.toolCallId, content: msg.content })\n }\n }\n\n return result\n}\n\nfunction toOpenAITools(tools: ChatTool[]): unknown[] {\n return tools.map((tool) => ({\n type: 'function',\n function: { name: tool.name, description: tool.description, parameters: tool.parameters }\n }))\n}\n\ninterface AccumulatedToolCall {\n id: string\n name: string\n arguments: string\n}\n\n/**\n * 逐行解析 SSE 流。\n * 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。\n */\nasync function parseSSE(\n body: ReadableStream<Uint8Array>,\n signal: AbortSignal,\n handlers: ChatTransportHandlers\n): Promise<void> {\n const reader = body.getReader()\n const decoder = new TextDecoder()\n let buffer = ''\n\n // 按 OpenAI 流式协议中 tool_calls 的 index 累积参数片段\n const accumulated = new Map<number, AccumulatedToolCall>()\n let lastIndex = -1\n\n const flushToolCalls = () => {\n if (!handlers.onToolCall) return\n for (const call of accumulated.values()) {\n if (call.id && call.name) {\n handlers.onToolCall({ id: call.id, name: call.name, arguments: call.arguments })\n }\n }\n accumulated.clear()\n }\n\n const handleData = (data: string) => {\n if (data === '[DONE]') return\n\n let chunk: any\n try {\n chunk = JSON.parse(data)\n } catch {\n return\n }\n\n const delta = chunk.choices?.[0]?.delta\n if (!delta) return\n\n if (typeof delta.content === 'string' && delta.content) {\n handlers.onTextDelta(delta.content)\n }\n\n // 兼容 reasoning_content(DeepSeek)与 reasoning(部分 OpenAI 兼容端点)\n const reasoning = delta.reasoning_content ?? delta.reasoning\n if (typeof reasoning === 'string' && reasoning) {\n handlers.onReasoningDelta?.(reasoning)\n }\n\n if (Array.isArray(delta.tool_calls)) {\n for (const tc of delta.tool_calls) {\n const index: number = tc.index ?? 0\n // 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去\n if (index > lastIndex && accumulated.size > 0) flushToolCalls()\n lastIndex = Math.max(lastIndex, index)\n\n let call = accumulated.get(index)\n if (!call) {\n call = { id: '', name: '', arguments: '' }\n accumulated.set(index, call)\n }\n if (tc.id) call.id = tc.id\n if (tc.function?.name) call.name += tc.function.name\n if (tc.function?.arguments) call.arguments += tc.function.arguments\n }\n }\n }\n\n try {\n // 递归泵取代替 while 循环,避免 await-in-loop\n const pump = async (): Promise<void> => {\n const { done, value } = await reader.read()\n if (done || signal.aborted) return\n\n buffer += decoder.decode(value, { stream: true })\n\n let newlineIndex: number\n while ((newlineIndex = buffer.indexOf('\\n')) !== -1) {\n const line = buffer.slice(0, newlineIndex).trim()\n buffer = buffer.slice(newlineIndex + 1)\n if (line.startsWith('data:')) {\n handleData(line.slice(5).trim())\n }\n }\n\n return pump()\n }\n\n await pump()\n\n if (buffer.trim().startsWith('data:')) {\n handleData(buffer.trim().slice(5).trim())\n }\n } finally {\n reader.releaseLock()\n }\n\n flushToolCalls()\n}\n\n/** 校验 providers 并展开扁平模型列表 */\nfunction flattenModels(providers: ChatProvider[]): {\n models: ChatModelOption[]\n byModelId: Map<string, ChatProvider>\n} {\n if (!providers.length) {\n throw new Error('[createOpenAITransport] providers 不能为空')\n }\n\n const models: ChatModelOption[] = []\n const byModelId = new Map<string, ChatProvider>()\n\n for (const provider of providers) {\n if (!provider.models?.length) {\n throw new Error(`[createOpenAITransport] Provider \"${provider.id}\" 未配置 models`)\n }\n for (const model of provider.models) {\n if (byModelId.has(model.id)) {\n throw new Error(\n `[createOpenAITransport] 模型 id \"${model.id}\" 重复(跨 Provider 须全局唯一)`\n )\n }\n byModelId.set(model.id, provider)\n models.push({ ...model, providerId: provider.id, providerLabel: provider.label })\n }\n }\n\n return { models, byModelId }\n}\n\n/** 缺省:写入 OpenAI 兼容的 reasoning_effort */\nfunction defaultApplyReasoning(level: string, body: Record<string, unknown>) {\n body.reasoning_effort = level\n}\n\n/**\n * 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。\n * 按 request.model 路由到对应 Provider 的 endpoint;手写 fetch + SSE,无第三方依赖。\n */\nexport function createOpenAITransport(options: OpenAITransportOptions): OpenAITransport {\n const { providers, headers: globalHeaders, body: globalBody } = options\n const { models, byModelId } = flattenModels(providers)\n const defaultModel = models[0]!.id\n\n const send: ChatTransport = async (\n request: ChatTransportRequest,\n handlers: ChatTransportHandlers\n ) => {\n const { messages, systemPrompt, tools, signal, reasoningLevel } = request\n const modelId = request.model ?? defaultModel\n const provider = byModelId.get(modelId)\n\n if (!provider) {\n handlers.onError?.(new Error(`未找到模型 \"${modelId}\" 对应的 Provider`))\n return\n }\n\n const requestBody: Record<string, unknown> = {\n ...globalBody,\n model: modelId,\n stream: true,\n messages: toOpenAIMessages(messages, systemPrompt),\n ...(tools?.length ? { tools: toOpenAITools(tools) } : {})\n }\n\n if (reasoningLevel) {\n const apply = provider.applyReasoning ?? defaultApplyReasoning\n apply(reasoningLevel, requestBody)\n }\n\n let response: Response\n try {\n response = await fetch(provider.endpoint, {\n method: 'POST',\n signal,\n headers: {\n 'Content-Type': 'application/json',\n ...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}),\n ...globalHeaders,\n ...provider.headers\n },\n body: JSON.stringify(requestBody)\n })\n } catch (error) {\n if (!signal.aborted) {\n handlers.onError?.(error instanceof Error ? error : new Error(String(error)))\n }\n return\n }\n\n if (!response.ok) {\n const text = await response.text().catch(() => '')\n handlers.onError?.(\n new Error(`请求失败(${response.status}):${text || response.statusText}`)\n )\n return\n }\n\n if (!response.body) {\n handlers.onError?.(new Error('响应不包含可读取的流'))\n return\n }\n\n try {\n await parseSSE(response.body, signal, handlers)\n } catch (error) {\n if (!signal.aborted) {\n handlers.onError?.(error instanceof Error ? error : new Error(String(error)))\n }\n }\n }\n\n return Object.assign(send, { models, defaultModel })\n}\n"],"mappings":"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"}
@@ -57,7 +57,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
57
57
  props,
58
58
  emit
59
59
  });
60
- /** 空会话(无可见消息):输入区垂直居中,欢迎区(orb)贴于输入区上方并与之左对齐 */
60
+ /** 空会话(无可见消息):输入区垂直居中;欢迎区钉在滚动容器外、贴于输入框上方 */
61
61
  const isEmpty = computed(() => !messages.value.some((msg) => msg.role !== "tool"));
62
62
  /** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */
63
63
  const panelCalls = computed(() => {
@@ -1 +1 @@
1
- {"version":3,"file":"ai-chat.js","names":["$slots"],"sources":["../../../src/components/ai-chat/ai-chat.vue"],"sourcesContent":["<template>\n <ULayout\n ref=\"layoutRef\"\n :class=\"[cls.b, bem.is('resizing', resizing)]\"\n :cols=\"layoutCols\"\n rows=\"1fr\"\n :resizable=\"panelOpen\"\n :col-min-sizes=\"panelOpen ? [MAIN_MIN_WIDTH, PANEL_MIN_WIDTH] : undefined\"\n @resize-start=\"resizing = true\"\n @resize-end=\"resizing = false\"\n >\n <div :class=\"[cls.e('main'), bem.is('empty', isEmpty)]\">\n <MessageList\n :messages=\"messages\"\n :welcome=\"welcome\"\n :running=\"running\"\n :renderer-props=\"rendererProps\"\n @respond=\"respondToolCall\"\n @regenerate=\"regenerate\"\n @welcome-click=\"(text) => handleSend(text, [])\"\n >\n <template v-if=\"$slots.welcome\" #welcome>\n <slot name=\"welcome\" />\n </template>\n </MessageList>\n\n <QueueList\n :queue=\"queue\"\n @start-now=\"startQueued\"\n @edit=\"handleQueueEdit\"\n @remove=\"removeQueued\"\n />\n\n <ChatInput\n ref=\"chatInputRef\"\n v-model:model=\"model\"\n v-model:reasoning-level=\"reasoningLevel\"\n :running=\"running\"\n :models=\"models\"\n :placeholder=\"placeholder\"\n :accept=\"accept\"\n :max-attachment-size=\"maxAttachmentSize\"\n @send=\"handleSend\"\n @abort=\"abort\"\n />\n </div>\n\n <!-- 侧边面板(renderTo: 'panel' 的工具渲染区),与会话区宽度经 ULayout 拖拽手柄调节 -->\n <SidePanel\n v-if=\"activePanelCall\"\n ref=\"panelRef\"\n :tool-call=\"activePanelCall\"\n @close=\"closePanel\"\n />\n </ULayout>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useResizeObserver } from '@veltra/compositions'\nimport { ULayout } from '@veltra/desktop'\nimport { bem } from '@veltra/utils'\nimport { computed, provide, ref, useSlots, useTemplateRef, watch } from 'vue'\n\nimport type { ChatAttachment, ChatToolCall } from '../../chat/types'\nimport { useChat } from '../../chat/use-chat'\nimport { createBuiltinTools } from '../../tools'\nimport type { _AiChatExposed, AiChatEmits, AiChatProps } from '../../types'\nimport ChatInput from './chat-input.vue'\nimport { AiChatDIKey } from './di'\nimport MessageList from './message-list.vue'\nimport QueueList from './queue-list.vue'\nimport SidePanel from './side-panel.vue'\n\ndefineOptions({ name: 'UAiChat' })\n\nconst props = defineProps<AiChatProps>()\n\nconst emit = defineEmits<AiChatEmits>()\n\ndefineSlots<{\n /** 空状态欢迎区插槽 */\n welcome(): any\n /** 按工具名自定义工具结果展示,如 tool-getWeather */\n [name: `tool-${string}`]: (scope: { toolCall: ChatToolCall }) => any\n}>()\n\nconst cls = bem('ai-chat')\n\nconst slots = useSlots()\n\n/** 内置 + 用户工具(同名内置优先),供工具卡片解析 icon/label/render/renderTo/autoCollapse */\nconst toolMap = computed(() => {\n const builtins = createBuiltinTools()\n const names = new Set(builtins.map((t) => t.name))\n const tools = [...builtins, ...(props.tools ?? []).filter((t) => !names.has(t.name))]\n return Object.fromEntries(tools.map((t) => [t.name, t]))\n})\n\nconst {\n messages,\n model,\n reasoningLevel,\n running,\n queue,\n send,\n abort,\n regenerate,\n clear,\n respondToolCall,\n enqueue,\n startQueued,\n removeQueued\n} = useChat({ props, emit })\n\n/** 空会话(无可见消息):输入区垂直居中,欢迎区(orb)贴于输入区上方并与之左对齐 */\nconst isEmpty = computed(() => !messages.value.some((msg) => msg.role !== 'tool'))\n\n/** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */\nconst panelCalls = computed(() => {\n const calls: ChatToolCall[] = []\n for (const msg of messages.value) {\n if (msg.role !== 'assistant' || !msg.toolCalls) continue\n for (const call of msg.toolCalls) {\n if (toolMap.value[call.name]?.renderTo === 'panel') calls.push(call)\n }\n }\n return calls\n})\n\n/** 当前展示在右侧面板的工具调用 id;null 表示面板关闭 */\nconst activePanelCallId = ref<string | null>(null)\n\nconst activePanelCall = computed(() => {\n if (!activePanelCallId.value) return null\n return panelCalls.value.find((call) => call.id === activePanelCallId.value) ?? null\n})\n\nconst panelOpen = computed(() => activePanelCall.value != null)\n\n// 出现新的面板工具调用时自动打开并聚焦到它\nwatch(\n () => panelCalls.value[panelCalls.value.length - 1]?.id,\n (id) => {\n if (id) activePanelCallId.value = id\n }\n)\n\n// 聚焦的调用消失(清空消息 / 重新生成)时,回落到最近一次面板调用或关闭\nwatch(panelCalls, (calls) => {\n const id = activePanelCallId.value\n if (id && !calls.some((call) => call.id === id)) {\n activePanelCallId.value = calls[calls.length - 1]?.id ?? null\n }\n})\n\n/** 打开面板并聚焦到指定工具调用(工具卡片「查看面板」入口,经 DI 下发) */\nconst openPanel = (toolCallId: string) => {\n if (panelCalls.value.some((call) => call.id === toolCallId)) {\n activePanelCallId.value = toolCallId\n }\n}\n\nconst closePanel = () => {\n activePanelCallId.value = null\n}\n\nprovide(AiChatDIKey, { cls, slots, tools: toolMap, openPanel })\n\n/**\n * 面板宽度:经 ULayout 列轨施加(cols = 1fr + 面板宽度),\n * 拖拽手柄由 ULayout 提供(colMinSizes 约束会话区 / 面板最小宽度)。\n */\nconst PANEL_MIN_WIDTH = 320\nconst MAIN_MIN_WIDTH = 360\n/** 面板打开时默认给会话区留的宽度(即面板默认尽可能大) */\nconst MAIN_DEFAULT_WIDTH = 860\n\nconst layoutRef = useTemplateRef<InstanceType<typeof ULayout>>('layoutRef')\nconst panelRef = useTemplateRef<InstanceType<typeof SidePanel>>('panelRef')\nconst panelWidth = ref(PANEL_MIN_WIDTH)\nconst resizing = ref(false)\n\nconst layoutCols = computed(() => {\n return panelOpen.value ? ['1fr', `${panelWidth.value}px`] : ['1fr']\n})\n\nconst containerWidth = () => (layoutRef.value?.$el as HTMLElement | undefined)?.clientWidth ?? 0\n\n/** 面板默认宽度:容器宽减去会话区默认保留宽度(缺省给会话区留 MAIN_DEFAULT_WIDTH) */\nconst defaultPanelWidth = () => {\n const width = containerWidth()\n return width ? width - MAIN_DEFAULT_WIDTH : 420\n}\n\n/** 钳位面板宽度在 [PANEL_MIN_WIDTH, 容器宽 - MAIN_MIN_WIDTH] 区间 */\nconst clampPanelWidth = (width: number) => {\n const width0 = containerWidth()\n const maxWidth = width0\n ? Math.max(PANEL_MIN_WIDTH, width0 - MAIN_MIN_WIDTH)\n : Number.MAX_SAFE_INTEGER\n return Math.min(Math.max(width, PANEL_MIN_WIDTH), maxWidth)\n}\n\n// 聚焦到指定了 panelWidth 的工具调用时应用其宽度;面板从关闭到打开且工具未指定宽度时\n// 应用默认宽(会话区留 860px);面板已打开时切换聚焦保持当前宽度(含用户拖拽结果)\nwatch(activePanelCall, (call, prevCall) => {\n if (!call) return\n const toolWidth = toolMap.value[call.name]?.panelWidth\n const width = toolWidth ?? (prevCall ? undefined : defaultPanelWidth())\n if (typeof width !== 'number') return\n panelWidth.value = clampPanelWidth(width)\n})\n\n// 跟踪面板实际宽度(含用户拖拽结果):面板关闭重开、切换到未指定宽度的工具时保持\nuseResizeObserver({\n targets: computed(() => (panelRef.value?.$el as HTMLElement | undefined) ?? undefined),\n onResize(entries) {\n // borderBoxSize 随通知携带(含边框、零布局开销),不读 offsetWidth/clientWidth\n const inlineSize = entries[0]?.borderBoxSize?.[0]?.inlineSize\n if (!inlineSize) return\n const width = Math.round(inlineSize)\n // 滞回:忽略亚像素/边框级的微小偏差(< 2px),阻断任何「观测→回写→再观测」的微缩反馈回路\n if (Math.abs(width - panelWidth.value) < 2) return\n panelWidth.value = width\n }\n})\n\nconst chatInputRef = useTemplateRef<InstanceType<typeof ChatInput>>('chatInputRef')\n\n/**\n * 编辑中的队列项原后继 id(编辑态标记):\n * undefined 表示无编辑;null 表示原位置在队尾;否则重新提交时插回该后继之前,\n * 使前后项顺序不受编辑影响(前项已执行则该项自然成为最前待执行项)。\n */\nconst editingSuccessor = ref<string | null | undefined>(undefined)\n\n/** 编辑队列项:取回输入框重新编辑;已有未完成编辑时先按锚点还原,避免内容丢失 */\nconst handleQueueEdit = (id: string) => {\n restoreEditing()\n\n const index = queue.value.findIndex((item) => item.id === id)\n if (index === -1) return\n editingSuccessor.value = queue.value[index + 1]?.id ?? null\n\n const item = removeQueued(id)\n if (item) chatInputRef.value?.setContent(item.content)\n}\n\n/** 将输入框中未提交的编辑内容按原锚点插回队列 */\nconst restoreEditing = () => {\n if (editingSuccessor.value === undefined) return\n const content = chatInputRef.value?.getContent().trim()\n if (content) enqueue(content, undefined, editingSuccessor.value ?? undefined)\n editingSuccessor.value = undefined\n}\n\nconst handleSend = (content: string, attachments: ChatAttachment[]) => {\n // 编辑态提交:插回原锚点位置(会话进行中则留在队列,空闲时由 enqueue 自动消耗队首)\n if (editingSuccessor.value !== undefined) {\n enqueue(content, attachments, editingSuccessor.value ?? undefined)\n editingSuccessor.value = undefined\n return\n }\n send(content, attachments)\n}\n\ndefineExpose<_AiChatExposed>({\n send,\n abort,\n regenerate,\n clear,\n queue,\n startQueued,\n removeQueued,\n enqueue\n})\n</script>\n"],"mappings":"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"}
1
+ {"version":3,"file":"ai-chat.js","names":["$slots"],"sources":["../../../src/components/ai-chat/ai-chat.vue"],"sourcesContent":["<template>\n <ULayout\n ref=\"layoutRef\"\n :class=\"[cls.b, bem.is('resizing', resizing)]\"\n :cols=\"layoutCols\"\n rows=\"1fr\"\n :resizable=\"panelOpen\"\n :col-min-sizes=\"panelOpen ? [MAIN_MIN_WIDTH, PANEL_MIN_WIDTH] : undefined\"\n @resize-start=\"resizing = true\"\n @resize-end=\"resizing = false\"\n >\n <div :class=\"[cls.e('main'), bem.is('empty', isEmpty)]\">\n <MessageList\n :messages=\"messages\"\n :welcome=\"welcome\"\n :running=\"running\"\n :renderer-props=\"rendererProps\"\n @respond=\"respondToolCall\"\n @regenerate=\"regenerate\"\n @welcome-click=\"(text) => handleSend(text, [])\"\n >\n <template v-if=\"$slots.welcome\" #welcome>\n <slot name=\"welcome\" />\n </template>\n </MessageList>\n\n <QueueList\n :queue=\"queue\"\n @start-now=\"startQueued\"\n @edit=\"handleQueueEdit\"\n @remove=\"removeQueued\"\n />\n\n <ChatInput\n ref=\"chatInputRef\"\n v-model:model=\"model\"\n v-model:reasoning-level=\"reasoningLevel\"\n :running=\"running\"\n :models=\"models\"\n :placeholder=\"placeholder\"\n :accept=\"accept\"\n :max-attachment-size=\"maxAttachmentSize\"\n @send=\"handleSend\"\n @abort=\"abort\"\n />\n </div>\n\n <!-- 侧边面板(renderTo: 'panel' 的工具渲染区),与会话区宽度经 ULayout 拖拽手柄调节 -->\n <SidePanel\n v-if=\"activePanelCall\"\n ref=\"panelRef\"\n :tool-call=\"activePanelCall\"\n @close=\"closePanel\"\n />\n </ULayout>\n</template>\n\n<script lang=\"ts\" setup>\nimport { useResizeObserver } from '@veltra/compositions'\nimport { ULayout } from '@veltra/desktop'\nimport { bem } from '@veltra/utils'\nimport { computed, provide, ref, useSlots, useTemplateRef, watch } from 'vue'\n\nimport type { ChatAttachment, ChatToolCall } from '../../chat/types'\nimport { useChat } from '../../chat/use-chat'\nimport { createBuiltinTools } from '../../tools'\nimport type { _AiChatExposed, AiChatEmits, AiChatProps } from '../../types'\nimport ChatInput from './chat-input.vue'\nimport { AiChatDIKey } from './di'\nimport MessageList from './message-list.vue'\nimport QueueList from './queue-list.vue'\nimport SidePanel from './side-panel.vue'\n\ndefineOptions({ name: 'UAiChat' })\n\nconst props = defineProps<AiChatProps>()\n\nconst emit = defineEmits<AiChatEmits>()\n\ndefineSlots<{\n /** 空闲欢迎区插槽(输入框上方;工作中跳到列表末尾) */\n welcome(): any\n /** 按工具名自定义工具结果展示,如 tool-getWeather */\n [name: `tool-${string}`]: (scope: { toolCall: ChatToolCall }) => any\n}>()\n\nconst cls = bem('ai-chat')\n\nconst slots = useSlots()\n\n/** 内置 + 用户工具(同名内置优先),供工具卡片解析 icon/label/render/renderTo/autoCollapse */\nconst toolMap = computed(() => {\n const builtins = createBuiltinTools()\n const names = new Set(builtins.map((t) => t.name))\n const tools = [...builtins, ...(props.tools ?? []).filter((t) => !names.has(t.name))]\n return Object.fromEntries(tools.map((t) => [t.name, t]))\n})\n\nconst {\n messages,\n model,\n reasoningLevel,\n running,\n queue,\n send,\n abort,\n regenerate,\n clear,\n respondToolCall,\n enqueue,\n startQueued,\n removeQueued\n} = useChat({ props, emit })\n\n/** 空会话(无可见消息):输入区垂直居中;欢迎区钉在滚动容器外、贴于输入框上方 */\nconst isEmpty = computed(() => !messages.value.some((msg) => msg.role !== 'tool'))\n\n/** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */\nconst panelCalls = computed(() => {\n const calls: ChatToolCall[] = []\n for (const msg of messages.value) {\n if (msg.role !== 'assistant' || !msg.toolCalls) continue\n for (const call of msg.toolCalls) {\n if (toolMap.value[call.name]?.renderTo === 'panel') calls.push(call)\n }\n }\n return calls\n})\n\n/** 当前展示在右侧面板的工具调用 id;null 表示面板关闭 */\nconst activePanelCallId = ref<string | null>(null)\n\nconst activePanelCall = computed(() => {\n if (!activePanelCallId.value) return null\n return panelCalls.value.find((call) => call.id === activePanelCallId.value) ?? null\n})\n\nconst panelOpen = computed(() => activePanelCall.value != null)\n\n// 出现新的面板工具调用时自动打开并聚焦到它\nwatch(\n () => panelCalls.value[panelCalls.value.length - 1]?.id,\n (id) => {\n if (id) activePanelCallId.value = id\n }\n)\n\n// 聚焦的调用消失(清空消息 / 重新生成)时,回落到最近一次面板调用或关闭\nwatch(panelCalls, (calls) => {\n const id = activePanelCallId.value\n if (id && !calls.some((call) => call.id === id)) {\n activePanelCallId.value = calls[calls.length - 1]?.id ?? null\n }\n})\n\n/** 打开面板并聚焦到指定工具调用(工具卡片「查看面板」入口,经 DI 下发) */\nconst openPanel = (toolCallId: string) => {\n if (panelCalls.value.some((call) => call.id === toolCallId)) {\n activePanelCallId.value = toolCallId\n }\n}\n\nconst closePanel = () => {\n activePanelCallId.value = null\n}\n\nprovide(AiChatDIKey, { cls, slots, tools: toolMap, openPanel })\n\n/**\n * 面板宽度:经 ULayout 列轨施加(cols = 1fr + 面板宽度),\n * 拖拽手柄由 ULayout 提供(colMinSizes 约束会话区 / 面板最小宽度)。\n */\nconst PANEL_MIN_WIDTH = 320\nconst MAIN_MIN_WIDTH = 360\n/** 面板打开时默认给会话区留的宽度(即面板默认尽可能大) */\nconst MAIN_DEFAULT_WIDTH = 860\n\nconst layoutRef = useTemplateRef<InstanceType<typeof ULayout>>('layoutRef')\nconst panelRef = useTemplateRef<InstanceType<typeof SidePanel>>('panelRef')\nconst panelWidth = ref(PANEL_MIN_WIDTH)\nconst resizing = ref(false)\n\nconst layoutCols = computed(() => {\n return panelOpen.value ? ['1fr', `${panelWidth.value}px`] : ['1fr']\n})\n\nconst containerWidth = () => (layoutRef.value?.$el as HTMLElement | undefined)?.clientWidth ?? 0\n\n/** 面板默认宽度:容器宽减去会话区默认保留宽度(缺省给会话区留 MAIN_DEFAULT_WIDTH) */\nconst defaultPanelWidth = () => {\n const width = containerWidth()\n return width ? width - MAIN_DEFAULT_WIDTH : 420\n}\n\n/** 钳位面板宽度在 [PANEL_MIN_WIDTH, 容器宽 - MAIN_MIN_WIDTH] 区间 */\nconst clampPanelWidth = (width: number) => {\n const width0 = containerWidth()\n const maxWidth = width0\n ? Math.max(PANEL_MIN_WIDTH, width0 - MAIN_MIN_WIDTH)\n : Number.MAX_SAFE_INTEGER\n return Math.min(Math.max(width, PANEL_MIN_WIDTH), maxWidth)\n}\n\n// 聚焦到指定了 panelWidth 的工具调用时应用其宽度;面板从关闭到打开且工具未指定宽度时\n// 应用默认宽(会话区留 860px);面板已打开时切换聚焦保持当前宽度(含用户拖拽结果)\nwatch(activePanelCall, (call, prevCall) => {\n if (!call) return\n const toolWidth = toolMap.value[call.name]?.panelWidth\n const width = toolWidth ?? (prevCall ? undefined : defaultPanelWidth())\n if (typeof width !== 'number') return\n panelWidth.value = clampPanelWidth(width)\n})\n\n// 跟踪面板实际宽度(含用户拖拽结果):面板关闭重开、切换到未指定宽度的工具时保持\nuseResizeObserver({\n targets: computed(() => (panelRef.value?.$el as HTMLElement | undefined) ?? undefined),\n onResize(entries) {\n // borderBoxSize 随通知携带(含边框、零布局开销),不读 offsetWidth/clientWidth\n const inlineSize = entries[0]?.borderBoxSize?.[0]?.inlineSize\n if (!inlineSize) return\n const width = Math.round(inlineSize)\n // 滞回:忽略亚像素/边框级的微小偏差(< 2px),阻断任何「观测→回写→再观测」的微缩反馈回路\n if (Math.abs(width - panelWidth.value) < 2) return\n panelWidth.value = width\n }\n})\n\nconst chatInputRef = useTemplateRef<InstanceType<typeof ChatInput>>('chatInputRef')\n\n/**\n * 编辑中的队列项原后继 id(编辑态标记):\n * undefined 表示无编辑;null 表示原位置在队尾;否则重新提交时插回该后继之前,\n * 使前后项顺序不受编辑影响(前项已执行则该项自然成为最前待执行项)。\n */\nconst editingSuccessor = ref<string | null | undefined>(undefined)\n\n/** 编辑队列项:取回输入框重新编辑;已有未完成编辑时先按锚点还原,避免内容丢失 */\nconst handleQueueEdit = (id: string) => {\n restoreEditing()\n\n const index = queue.value.findIndex((item) => item.id === id)\n if (index === -1) return\n editingSuccessor.value = queue.value[index + 1]?.id ?? null\n\n const item = removeQueued(id)\n if (item) chatInputRef.value?.setContent(item.content)\n}\n\n/** 将输入框中未提交的编辑内容按原锚点插回队列 */\nconst restoreEditing = () => {\n if (editingSuccessor.value === undefined) return\n const content = chatInputRef.value?.getContent().trim()\n if (content) enqueue(content, undefined, editingSuccessor.value ?? undefined)\n editingSuccessor.value = undefined\n}\n\nconst handleSend = (content: string, attachments: ChatAttachment[]) => {\n // 编辑态提交:插回原锚点位置(会话进行中则留在队列,空闲时由 enqueue 自动消耗队首)\n if (editingSuccessor.value !== undefined) {\n enqueue(content, attachments, editingSuccessor.value ?? undefined)\n editingSuccessor.value = undefined\n return\n }\n send(content, attachments)\n}\n\ndefineExpose<_AiChatExposed>({\n send,\n abort,\n regenerate,\n clear,\n queue,\n startQueued,\n removeQueued,\n enqueue\n})\n</script>\n"],"mappings":"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"}
@@ -46,7 +46,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
46
46
  reasoningExpanded.value = true;
47
47
  reasoningStick.value = true;
48
48
  } else if (status) reasoningExpanded.value = false;
49
- });
49
+ }, { immediate: true });
50
50
  watch(() => props.message.reasoning, async () => {
51
51
  if (!isThinking.value || !reasoningExpanded.value || !reasoningStick.value) return;
52
52
  await nextTick();
@@ -1 +1 @@
1
- {"version":3,"file":"message-item.js","names":[],"sources":["../../../src/components/ai-chat/message-item.vue"],"sourcesContent":["<template>\n <div\n :class=\"[cls.e('message'), cls.em('message', message.role), bem.is('streaming', isStreaming)]\"\n >\n <!-- 用户消息:右对齐气泡 -->\n <template v-if=\"message.role === 'user'\">\n <div :class=\"cls.e('message-bubble')\">\n <div v-if=\"message.attachments?.length\" :class=\"cls.e('message-attachments')\">\n <img\n v-for=\"att in message.attachments\"\n :key=\"att.dataUrl\"\n :src=\"att.dataUrl\"\n :alt=\"att.name\"\n :class=\"cls.e('message-attachment')\"\n />\n </div>\n <div v-if=\"message.content\" :class=\"cls.e('message-text')\">{{ message.content }}</div>\n </div>\n </template>\n\n <!-- assistant 消息 -->\n <template v-else>\n <div\n v-if=\"message.reasoning\"\n :class=\"[cls.e('reasoning'), bem.is('expanded', reasoningExpanded)]\"\n >\n <div :class=\"cls.e('reasoning-header')\" @click=\"toggleReasoning\">\n <UIcon :class=\"[cls.e('reasoning-icon'), bem.is('loading', isThinking)]\">\n <Loading v-if=\"isThinking\" />\n <InfoCircle v-else />\n </UIcon>\n <span :class=\"[cls.e('reasoning-title'), isThinking && 'u-shine']\">\n {{ isThinking ? '思考中…' : '思考过程' }}\n </span>\n <UIcon :class=\"cls.e('reasoning-chevron')\"><ArrowRight /></UIcon>\n </div>\n <!-- 折叠时完全卸载内容 DOM:思考文本可能很长,常驻挂载浪费内存与渲染成本 -->\n <div v-if=\"reasoningExpanded\" :class=\"cls.e('reasoning-content')\">\n <UScroll\n ref=\"reasoningScrollRef\"\n container-style=\"max-height: 220px\"\n @scroll=\"handleReasoningScroll\"\n @wheel.passive=\"handleReasoningWheel\"\n >\n <div :class=\"cls.e('reasoning-text')\">{{ message.reasoning }}</div>\n </UScroll>\n </div>\n </div>\n\n <MarkdownRender\n v-if=\"message.content || isStreaming\"\n mode=\"chat\"\n :class=\"cls.e('message-content')\"\n :content=\"message.content\"\n :final=\"!isStreaming\"\n :fade=\"false\"\n v-bind=\"rendererProps\"\n />\n\n <ToolCallCard\n v-for=\"call in message.toolCalls ?? []\"\n :key=\"call.id\"\n :tool-call=\"call\"\n @respond=\"(approved) => emit('respond', call.id, approved)\"\n />\n\n <div v-if=\"message.status === 'error'\" :class=\"cls.e('message-status')\">生成出错</div>\n <div v-else-if=\"message.status === 'aborted'\" :class=\"cls.e('message-status')\">\n 已停止生成\n </div>\n\n <div v-if=\"showActions\" :class=\"cls.e('message-actions')\">\n <UButton\n size=\"small\"\n plain\n circle\n :type=\"copied ? 'success' : 'primary'\"\n :icon=\"copied ? CircleCheck : Copy\"\n :disabled=\"!canCopy\"\n :title=\"copied ? '已复制' : '复制'\"\n @click=\"handleCopy\"\n />\n <UButton\n size=\"small\"\n plain\n circle\n type=\"primary\"\n :icon=\"Refresh\"\n title=\"重新生成\"\n @click=\"emit('regenerate')\"\n />\n </div>\n </template>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { message as toast, UButton, UIcon, UScroll } from '@veltra/desktop'\nimport type { ScrollPosition } from '@veltra/desktop'\nimport { ArrowRight, CircleCheck, Copy, InfoCircle, Loading, Refresh } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport MarkdownRender from 'markstream-vue'\nimport { computed, inject, nextTick, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'\n\nimport type { ChatMessage } from '../../chat/types'\nimport { AiChatDIKey } from './di'\nimport ToolCallCard from './tool-call.vue'\n\ndefineOptions({ name: 'UAiChatMessageItem' })\n\nconst props = defineProps<{\n message: ChatMessage\n /** 是否为最后一条可见消息(决定是否显示重新生成) */\n isLast: boolean\n /** 透传给 MarkdownRender 的属性 */\n rendererProps?: Record<string, unknown>\n}>()\n\nconst emit = defineEmits<{\n (e: 'respond', toolCallId: string, approved: boolean): void\n (e: 'regenerate'): void\n}>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\nconst isStreaming = computed(() => props.message.status === 'streaming')\n/** 思考中:流式且正文还未开始输出 */\nconst isThinking = computed(() => isStreaming.value && !props.message.content)\n\nconst reasoningExpanded = ref(true)\n\n/** 思考区吸附底部:用户在思考区内上滚时取消吸附,回到底部后恢复 */\nconst reasoningStick = ref(true)\n\n/** 思考内容滚动区:流式输出时吸附底部,始终展示最新思考 */\nconst reasoningScrollRef = useTemplateRef<InstanceType<typeof UScroll>>('reasoningScrollRef')\n\nconst toggleReasoning = async () => {\n reasoningExpanded.value = !reasoningExpanded.value\n if (!reasoningExpanded.value) return\n // 重新展开时恢复吸附并定位到最新思考\n reasoningStick.value = true\n await nextTick()\n reasoningScrollRef.value?.scrollTo({ y: Number.MAX_SAFE_INTEGER })\n}\n\nconst handleReasoningWheel = (e: WheelEvent) => {\n if (e.deltaY < 0) reasoningStick.value = false\n}\n\nconst handleReasoningScroll = (position: Required<ScrollPosition>) => {\n if (position.sh - position.ch - position.y < 8) reasoningStick.value = true\n}\n\n// 流式结束后自动折叠思考过程\nwatch(\n () => props.message.status,\n (status) => {\n if (status === 'streaming') {\n reasoningExpanded.value = true\n reasoningStick.value = true\n } else if (status) {\n reasoningExpanded.value = false\n }\n }\n)\n\nwatch(\n () => props.message.reasoning,\n async () => {\n if (!isThinking.value || !reasoningExpanded.value || !reasoningStick.value) return\n await nextTick()\n // 等待 DOM 更新期间用户可能已上滚,滚动前重新确认吸附状态\n if (!reasoningStick.value) return\n reasoningScrollRef.value?.scrollTo({ y: Number.MAX_SAFE_INTEGER })\n }\n)\n\nconst showActions = computed(() => {\n if (props.message.role !== 'assistant' || !props.isLast) return false\n return ['done', 'error', 'aborted'].includes(props.message.status ?? '')\n})\n\nconst canCopy = computed(() => !!props.message.content?.trim())\n\nconst copied = ref(false)\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\n\nconst handleCopy = async () => {\n const text = props.message.content?.trim()\n if (!text) return\n try {\n await navigator.clipboard.writeText(text)\n copied.value = true\n toast.success('已复制到剪贴板')\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => {\n copied.value = false\n }, 2000)\n } catch {\n toast.error('复制失败,请检查浏览器权限')\n }\n}\n\nonBeforeUnmount(() => clearTimeout(copyTimer))\n</script>\n"],"mappings":"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"}
1
+ {"version":3,"file":"message-item.js","names":[],"sources":["../../../src/components/ai-chat/message-item.vue"],"sourcesContent":["<template>\n <div\n :class=\"[cls.e('message'), cls.em('message', message.role), bem.is('streaming', isStreaming)]\"\n >\n <!-- 用户消息:右对齐气泡 -->\n <template v-if=\"message.role === 'user'\">\n <div :class=\"cls.e('message-bubble')\">\n <div v-if=\"message.attachments?.length\" :class=\"cls.e('message-attachments')\">\n <img\n v-for=\"att in message.attachments\"\n :key=\"att.dataUrl\"\n :src=\"att.dataUrl\"\n :alt=\"att.name\"\n :class=\"cls.e('message-attachment')\"\n />\n </div>\n <div v-if=\"message.content\" :class=\"cls.e('message-text')\">{{ message.content }}</div>\n </div>\n </template>\n\n <!-- assistant 消息 -->\n <template v-else>\n <div\n v-if=\"message.reasoning\"\n :class=\"[cls.e('reasoning'), bem.is('expanded', reasoningExpanded)]\"\n >\n <div :class=\"cls.e('reasoning-header')\" @click=\"toggleReasoning\">\n <UIcon :class=\"[cls.e('reasoning-icon'), bem.is('loading', isThinking)]\">\n <Loading v-if=\"isThinking\" />\n <InfoCircle v-else />\n </UIcon>\n <span :class=\"[cls.e('reasoning-title'), isThinking && 'u-shine']\">\n {{ isThinking ? '思考中…' : '思考过程' }}\n </span>\n <UIcon :class=\"cls.e('reasoning-chevron')\"><ArrowRight /></UIcon>\n </div>\n <!-- 折叠时完全卸载内容 DOM:思考文本可能很长,常驻挂载浪费内存与渲染成本 -->\n <div v-if=\"reasoningExpanded\" :class=\"cls.e('reasoning-content')\">\n <UScroll\n ref=\"reasoningScrollRef\"\n container-style=\"max-height: 220px\"\n @scroll=\"handleReasoningScroll\"\n @wheel.passive=\"handleReasoningWheel\"\n >\n <div :class=\"cls.e('reasoning-text')\">{{ message.reasoning }}</div>\n </UScroll>\n </div>\n </div>\n\n <MarkdownRender\n v-if=\"message.content || isStreaming\"\n mode=\"chat\"\n :class=\"cls.e('message-content')\"\n :content=\"message.content\"\n :final=\"!isStreaming\"\n :fade=\"false\"\n v-bind=\"rendererProps\"\n />\n\n <ToolCallCard\n v-for=\"call in message.toolCalls ?? []\"\n :key=\"call.id\"\n :tool-call=\"call\"\n @respond=\"(approved) => emit('respond', call.id, approved)\"\n />\n\n <div v-if=\"message.status === 'error'\" :class=\"cls.e('message-status')\">生成出错</div>\n <div v-else-if=\"message.status === 'aborted'\" :class=\"cls.e('message-status')\">\n 已停止生成\n </div>\n\n <div v-if=\"showActions\" :class=\"cls.e('message-actions')\">\n <UButton\n size=\"small\"\n plain\n circle\n :type=\"copied ? 'success' : 'primary'\"\n :icon=\"copied ? CircleCheck : Copy\"\n :disabled=\"!canCopy\"\n :title=\"copied ? '已复制' : '复制'\"\n @click=\"handleCopy\"\n />\n <UButton\n size=\"small\"\n plain\n circle\n type=\"primary\"\n :icon=\"Refresh\"\n title=\"重新生成\"\n @click=\"emit('regenerate')\"\n />\n </div>\n </template>\n </div>\n</template>\n\n<script lang=\"ts\" setup>\nimport { message as toast, UButton, UIcon, UScroll } from '@veltra/desktop'\nimport type { ScrollPosition } from '@veltra/desktop'\nimport { ArrowRight, CircleCheck, Copy, InfoCircle, Loading, Refresh } from '@veltra/icons/normal'\nimport { bem } from '@veltra/utils'\nimport MarkdownRender from 'markstream-vue'\nimport { computed, inject, nextTick, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'\n\nimport type { ChatMessage } from '../../chat/types'\nimport { AiChatDIKey } from './di'\nimport ToolCallCard from './tool-call.vue'\n\ndefineOptions({ name: 'UAiChatMessageItem' })\n\nconst props = defineProps<{\n message: ChatMessage\n /** 是否为最后一条可见消息(决定是否显示重新生成) */\n isLast: boolean\n /** 透传给 MarkdownRender 的属性 */\n rendererProps?: Record<string, unknown>\n}>()\n\nconst emit = defineEmits<{\n (e: 'respond', toolCallId: string, approved: boolean): void\n (e: 'regenerate'): void\n}>()\n\nconst di = inject(AiChatDIKey)\nconst cls = di?.cls ?? bem('ai-chat')\n\nconst isStreaming = computed(() => props.message.status === 'streaming')\n/** 思考中:流式且正文还未开始输出 */\nconst isThinking = computed(() => isStreaming.value && !props.message.content)\n\nconst reasoningExpanded = ref(true)\n\n/** 思考区吸附底部:用户在思考区内上滚时取消吸附,回到底部后恢复 */\nconst reasoningStick = ref(true)\n\n/** 思考内容滚动区:流式输出时吸附底部,始终展示最新思考 */\nconst reasoningScrollRef = useTemplateRef<InstanceType<typeof UScroll>>('reasoningScrollRef')\n\nconst toggleReasoning = async () => {\n reasoningExpanded.value = !reasoningExpanded.value\n if (!reasoningExpanded.value) return\n // 重新展开时恢复吸附并定位到最新思考\n reasoningStick.value = true\n await nextTick()\n reasoningScrollRef.value?.scrollTo({ y: Number.MAX_SAFE_INTEGER })\n}\n\nconst handleReasoningWheel = (e: WheelEvent) => {\n if (e.deltaY < 0) reasoningStick.value = false\n}\n\nconst handleReasoningScroll = (position: Required<ScrollPosition>) => {\n if (position.sh - position.ch - position.y < 8) reasoningStick.value = true\n}\n\n// 流式结束后自动折叠思考过程;immediate 保证终态消息重挂载(过程块展开/收起、载入历史会话)时呈折叠态\nwatch(\n () => props.message.status,\n (status) => {\n if (status === 'streaming') {\n reasoningExpanded.value = true\n reasoningStick.value = true\n } else if (status) {\n reasoningExpanded.value = false\n }\n },\n { immediate: true }\n)\n\nwatch(\n () => props.message.reasoning,\n async () => {\n if (!isThinking.value || !reasoningExpanded.value || !reasoningStick.value) return\n await nextTick()\n // 等待 DOM 更新期间用户可能已上滚,滚动前重新确认吸附状态\n if (!reasoningStick.value) return\n reasoningScrollRef.value?.scrollTo({ y: Number.MAX_SAFE_INTEGER })\n }\n)\n\nconst showActions = computed(() => {\n if (props.message.role !== 'assistant' || !props.isLast) return false\n return ['done', 'error', 'aborted'].includes(props.message.status ?? '')\n})\n\nconst canCopy = computed(() => !!props.message.content?.trim())\n\nconst copied = ref(false)\nlet copyTimer: ReturnType<typeof setTimeout> | undefined\n\nconst handleCopy = async () => {\n const text = props.message.content?.trim()\n if (!text) return\n try {\n await navigator.clipboard.writeText(text)\n copied.value = true\n toast.success('已复制到剪贴板')\n clearTimeout(copyTimer)\n copyTimer = setTimeout(() => {\n copied.value = false\n }, 2000)\n } catch {\n toast.error('复制失败,请检查浏览器权限')\n }\n}\n\nonBeforeUnmount(() => clearTimeout(copyTimer))\n</script>\n"],"mappings":"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"}
@@ -1,16 +1,17 @@
1
1
  import { AiChatDIKey } from "./di.js";
2
2
  import _sfc_main$1 from "../ai-orb/ai-orb.js";
3
3
  import _sfc_main$2 from "./message-item.js";
4
+ import _sfc_main$3 from "./turn-process.js";
4
5
  import { Fragment, Transition, computed, createBlock, createCommentVNode, createElementBlock, createElementVNode, createVNode, defineComponent, inject, nextTick, normalizeClass, onBeforeUnmount, openBlock, ref, renderList, renderSlot, shallowRef, toDisplayString, unref, useTemplateRef, watch, withCtx } from "vue";
5
6
  import { ArrowDown } from "@veltra/icons/normal";
6
7
  import { UIcon, UScroll } from "@veltra/desktop";
7
8
  import { bem } from "@veltra/utils";
8
9
  //#region src/components/ai-chat/message-list.vue
9
- const WELCOME_ORB_SIZE = 64;
10
- /** 工作指示活体球直径(px) */
11
- const WORKING_ORB_SIZE = 48;
12
- /** 生成结束 / 失败后工作球停留再隐藏 */
10
+ const ORB_SIZE = 48;
11
+ /** 生成结束 / 失败后工作球停留再跳回输入框上方 */
13
12
  const WORKING_LINGER_MS = 2500;
13
+ /** 两处活体球对向缩放的时长(与样式中的 jump 过渡一致) */
14
+ const JUMP_MS = 380;
14
15
  const _sfc_main = /*@__PURE__*/ defineComponent({
15
16
  name: "UAiChatMessageList",
16
17
  __name: "message-list",
@@ -26,12 +27,47 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
26
27
  "welcome-click"
27
28
  ],
28
29
  setup(__props, { emit: __emit }) {
29
- /** 欢迎区活体球直径(px) */
30
+ /** 空闲欢迎 / 工作指示共用活体球直径(px) */
30
31
  const props = __props;
31
32
  const emit = __emit;
32
33
  const cls = inject(AiChatDIKey)?.cls ?? bem("ai-chat");
33
34
  /** tool 消息不单独渲染,其内容体现在工具卡片的结果区 */
34
35
  const visibleMessages = computed(() => props.messages.filter((msg) => msg.role !== "tool"));
36
+ /** 一轮对话:以 user 消息为界,含本轮全部 assistant 消息(每次工具循环产生一条) */
37
+ const isFinalStatus = (msg) => msg?.status === "done" || msg?.status === "error" || msg?.status === "aborted";
38
+ const turns = computed(() => {
39
+ const grouped = [];
40
+ for (const msg of visibleMessages.value) {
41
+ if (msg.role === "user") {
42
+ grouped.push({
43
+ key: msg.id,
44
+ userMsg: msg,
45
+ assistants: []
46
+ });
47
+ continue;
48
+ }
49
+ const last = grouped[grouped.length - 1];
50
+ if (last) last.assistants.push(msg);
51
+ else grouped.push({
52
+ key: msg.id,
53
+ assistants: [msg]
54
+ });
55
+ }
56
+ return grouped.map((turn, index) => {
57
+ const processMsgs = turn.assistants.slice(0, -1);
58
+ const answerMsg = turn.assistants[turn.assistants.length - 1];
59
+ const isLastTurn = index === grouped.length - 1;
60
+ const processCollapsed = processMsgs.length > 0 && (!isLastTurn || !!answerMsg?.content || !props.running && isFinalStatus(answerMsg));
61
+ return {
62
+ key: turn.key,
63
+ userMsg: turn.userMsg,
64
+ processMsgs,
65
+ answerMsg,
66
+ processCollapsed,
67
+ isLast: isLastTurn && !!answerMsg
68
+ };
69
+ });
70
+ });
35
71
  const welcomeItems = computed(() => {
36
72
  const welcome = props.welcome;
37
73
  if (!welcome) return [];
@@ -61,11 +97,24 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
61
97
  welcomeIndex.value += 1;
62
98
  startWelcomeRotation();
63
99
  };
64
- watch(() => [welcomeItems.value.length, visibleMessages.value.length], () => {
65
- if (visibleMessages.value.length > 0) stopWelcomeRotation();
66
- else startWelcomeRotation();
67
- }, { immediate: true });
68
- onBeforeUnmount(stopWelcomeRotation);
100
+ /** 回答完毕 / 失败后工作球短暂停留,播完表情再跳回输入框上方 */
101
+ const finishing = ref(false);
102
+ /** 列表中的球正在缩小离开 */
103
+ const workingLeaving = ref(false);
104
+ /** 输入框上方的球正在缩小离开 */
105
+ const idleLeaving = ref(false);
106
+ /** 输入框上方的球从工作位放大回来(首屏出现不播) */
107
+ const idleEnter = ref(false);
108
+ let finishTimer;
109
+ let jumpTimer;
110
+ const clearJumpTimers = () => {
111
+ clearTimeout(finishTimer);
112
+ clearTimeout(jumpTimer);
113
+ };
114
+ /** 列表末尾的工作球:生成中 / 收尾停留 / 跳回过渡 */
115
+ const showWorking = computed(() => !!(props.running || finishing.value || workingLeaving.value));
116
+ /** 输入框上方的空闲欢迎:含工作开始时的缩小离开帧 */
117
+ const showIdleWelcome = computed(() => !props.running && !finishing.value || idleLeaving.value);
69
118
  const workingOrbRef = useTemplateRef("workingOrb");
70
119
  /** 工具调用失败的 id 去重集合:同一次失败只播一次沮丧表情 */
71
120
  const reactedToolErrorIds = /* @__PURE__ */ new Set();
@@ -84,13 +133,6 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
84
133
  workingOrbRef.value?.react("frustrated");
85
134
  }
86
135
  });
87
- /** 回答完毕 / 失败后工作球短暂停留,播完表情再隐藏 */
88
- const finishing = ref(false);
89
- let finishTimer;
90
- const clearFinishing = () => {
91
- clearTimeout(finishTimer);
92
- finishing.value = false;
93
- };
94
136
  /** 工作球状态:收尾停留 → idle;正文输出中 → speaking;其余生成阶段 → thinking */
95
137
  const workingOrbStatus = computed(() => {
96
138
  if (finishing.value) return "idle";
@@ -100,11 +142,22 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
100
142
  });
101
143
  watch(() => props.running, (running, prev) => {
102
144
  if (running) {
103
- clearFinishing();
145
+ clearJumpTimers();
146
+ const idleWasVisible = !prev && !finishing.value && !workingLeaving.value;
147
+ finishing.value = false;
148
+ workingLeaving.value = false;
149
+ idleEnter.value = false;
150
+ if (idleWasVisible) {
151
+ idleLeaving.value = true;
152
+ jumpTimer = setTimeout(() => {
153
+ idleLeaving.value = false;
154
+ }, JUMP_MS);
155
+ } else idleLeaving.value = false;
104
156
  return;
105
157
  }
106
158
  if (!prev) return;
107
159
  finishing.value = true;
160
+ idleLeaving.value = false;
108
161
  const last = visibleMessages.value[visibleMessages.value.length - 1];
109
162
  let reaction;
110
163
  if (last?.role === "assistant") {
@@ -112,9 +165,24 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
112
165
  else if (last.status === "error") reaction = "frustrated";
113
166
  }
114
167
  if (reaction) nextTick(() => workingOrbRef.value?.react(reaction));
115
- finishTimer = setTimeout(clearFinishing, WORKING_LINGER_MS);
168
+ finishTimer = setTimeout(() => {
169
+ finishing.value = false;
170
+ workingLeaving.value = true;
171
+ idleEnter.value = true;
172
+ jumpTimer = setTimeout(() => {
173
+ workingLeaving.value = false;
174
+ idleEnter.value = false;
175
+ }, JUMP_MS);
176
+ }, WORKING_LINGER_MS);
177
+ });
178
+ watch(() => [welcomeItems.value.length, showIdleWelcome.value], () => {
179
+ if (showIdleWelcome.value) startWelcomeRotation();
180
+ else stopWelcomeRotation();
181
+ }, { immediate: true });
182
+ onBeforeUnmount(() => {
183
+ stopWelcomeRotation();
184
+ clearJumpTimers();
116
185
  });
117
- onBeforeUnmount(clearFinishing);
118
186
  const scrollRef = shallowRef();
119
187
  /** 是否吸附底部(用户上翻浏览历史时取消吸附) */
120
188
  const stickToBottom = ref(true);
@@ -183,60 +251,91 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
183
251
  onWheelPassive: handleWheel
184
252
  }, {
185
253
  default: withCtx(() => [
186
- !visibleMessages.value.length ? (openBlock(), createElementBlock("div", {
187
- key: 0,
188
- class: normalizeClass(unref(cls).e("welcome"))
189
- }, [renderSlot(_ctx.$slots, "welcome", {}, () => [createElementVNode("div", { class: normalizeClass(unref(cls).e("welcome-inner")) }, [
190
- createVNode(_sfc_main$1, {
191
- size: WELCOME_ORB_SIZE,
192
- onClick: cycleWelcome
193
- }),
194
- createCommentVNode(" 快捷提问逐条展示在球右侧并自动轮换:点文案发送,点球立即换下一条。\n 固定锚点布局:球位置钉死不动,气泡只向右延展,避免轮换时水平瞬移 "),
195
- welcomeItems.value.length ? (openBlock(), createElementBlock("div", {
196
- key: 0,
197
- class: normalizeClass(unref(cls).e("welcome-item-wrap"))
198
- }, [createVNode(Transition, {
199
- name: unref(cls).em("welcome", "swap"),
200
- mode: "out-in"
201
- }, {
202
- default: withCtx(() => [currentWelcomeItem.value ? (openBlock(), createElementBlock("button", {
203
- key: welcomeIndex.value,
204
- type: "button",
205
- class: normalizeClass(unref(cls).e("welcome-item")),
206
- onClick: _cache[0] || (_cache[0] = ($event) => emit("welcome-click", currentWelcomeItem.value))
207
- }, toDisplayString(currentWelcomeItem.value), 3)) : createCommentVNode("v-if", true)]),
208
- _: 1
209
- }, 8, ["name"])], 2)) : createCommentVNode("v-if", true)
210
- ], 2)])], 2)) : createCommentVNode("v-if", true),
211
- (openBlock(true), createElementBlock(Fragment, null, renderList(visibleMessages.value, (msg, index) => {
212
- return openBlock(), createBlock(_sfc_main$2, {
213
- key: msg.id,
214
- message: msg,
215
- "is-last": index === visibleMessages.value.length - 1,
216
- "renderer-props": __props.rendererProps,
217
- onRespond: _cache[1] || (_cache[1] = (id, approved) => emit("respond", id, approved)),
218
- onRegenerate: _cache[2] || (_cache[2] = ($event) => emit("regenerate"))
219
- }, null, 8, [
220
- "message",
221
- "is-last",
222
- "renderer-props"
223
- ]);
254
+ (openBlock(true), createElementBlock(Fragment, null, renderList(turns.value, (turn) => {
255
+ return openBlock(), createElementBlock(Fragment, { key: turn.key }, [
256
+ turn.userMsg ? (openBlock(), createBlock(_sfc_main$2, {
257
+ key: 0,
258
+ message: turn.userMsg,
259
+ "is-last": false,
260
+ "renderer-props": __props.rendererProps,
261
+ onRespond: _cache[0] || (_cache[0] = (id, approved) => emit("respond", id, approved)),
262
+ onRegenerate: _cache[1] || (_cache[1] = ($event) => emit("regenerate"))
263
+ }, null, 8, ["message", "renderer-props"])) : createCommentVNode("v-if", true),
264
+ createCommentVNode(" 最终答案开始输出后,本轮之前的思考/工具调用过程收进「已完成」折叠块 "),
265
+ turn.processCollapsed ? (openBlock(), createBlock(_sfc_main$3, {
266
+ key: 1,
267
+ messages: turn.processMsgs,
268
+ "renderer-props": __props.rendererProps,
269
+ onRespond: _cache[2] || (_cache[2] = (id, approved) => emit("respond", id, approved))
270
+ }, null, 8, ["messages", "renderer-props"])) : (openBlock(true), createElementBlock(Fragment, { key: 2 }, renderList(turn.processMsgs, (msg) => {
271
+ return openBlock(), createBlock(_sfc_main$2, {
272
+ key: msg.id,
273
+ message: msg,
274
+ "is-last": false,
275
+ "renderer-props": __props.rendererProps,
276
+ onRespond: _cache[3] || (_cache[3] = (id, approved) => emit("respond", id, approved)),
277
+ onRegenerate: _cache[4] || (_cache[4] = ($event) => emit("regenerate"))
278
+ }, null, 8, ["message", "renderer-props"]);
279
+ }), 128)),
280
+ turn.answerMsg ? (openBlock(), createBlock(_sfc_main$2, {
281
+ key: 3,
282
+ message: turn.answerMsg,
283
+ "is-last": turn.isLast,
284
+ "renderer-props": __props.rendererProps,
285
+ onRespond: _cache[5] || (_cache[5] = (id, approved) => emit("respond", id, approved)),
286
+ onRegenerate: _cache[6] || (_cache[6] = ($event) => emit("regenerate"))
287
+ }, null, 8, [
288
+ "message",
289
+ "is-last",
290
+ "renderer-props"
291
+ ])) : createCommentVNode("v-if", true)
292
+ ], 64);
224
293
  }), 128)),
225
- createCommentVNode(" 工作状态统一指示:生成中在列表末尾展示活体球 + 文案;结束 / 失败停留片刻再隐藏 "),
226
- __props.running || finishing.value ? (openBlock(), createElementBlock("div", {
227
- key: 1,
228
- class: normalizeClass(unref(cls).e("working"))
294
+ createCommentVNode(" 工作中:活体球从输入框上方跳到最新会话下面;结束停留后再跳回 "),
295
+ showWorking.value ? (openBlock(), createElementBlock("div", {
296
+ key: 0,
297
+ class: normalizeClass([unref(cls).e("working"), unref(bem).is("leaving", workingLeaving.value)])
229
298
  }, [createVNode(_sfc_main$1, {
230
299
  ref: "workingOrb",
231
- size: WORKING_ORB_SIZE,
300
+ size: ORB_SIZE,
232
301
  status: workingOrbStatus.value
233
302
  }, null, 8, ["status"]), __props.running ? (openBlock(), createElementBlock("span", {
234
303
  key: 0,
235
304
  class: normalizeClass([unref(cls).e("working-text"), "u-shine"])
236
305
  }, "工作中…", 2)) : createCommentVNode("v-if", true)], 2)) : createCommentVNode("v-if", true)
237
306
  ]),
238
- _: 3
307
+ _: 1
239
308
  }, 8, ["class"]),
309
+ createCommentVNode(" 空闲:始终钉在滚动容器外、输入框上方;工作开始时缩小离开、结束后放大回来 "),
310
+ showIdleWelcome.value ? (openBlock(), createElementBlock("div", {
311
+ key: 0,
312
+ class: normalizeClass([
313
+ unref(cls).e("welcome"),
314
+ unref(bem).is("leaving", idleLeaving.value),
315
+ unref(bem).is("enter", idleEnter.value)
316
+ ])
317
+ }, [renderSlot(_ctx.$slots, "welcome", {}, () => [createElementVNode("div", { class: normalizeClass(unref(cls).e("welcome-inner")) }, [
318
+ createVNode(_sfc_main$1, {
319
+ size: ORB_SIZE,
320
+ onClick: cycleWelcome
321
+ }),
322
+ createCommentVNode(" 快捷提问逐条展示在球右侧并自动轮换:点文案发送,点球立即换下一条。\n 固定锚点布局:球位置钉死不动,气泡只向右延展,避免轮换时水平瞬移 "),
323
+ welcomeItems.value.length ? (openBlock(), createElementBlock("div", {
324
+ key: 0,
325
+ class: normalizeClass(unref(cls).e("welcome-item-wrap"))
326
+ }, [createVNode(Transition, {
327
+ name: unref(cls).em("welcome", "swap"),
328
+ mode: "out-in"
329
+ }, {
330
+ default: withCtx(() => [currentWelcomeItem.value ? (openBlock(), createElementBlock("button", {
331
+ key: welcomeIndex.value,
332
+ type: "button",
333
+ class: normalizeClass(unref(cls).e("welcome-item")),
334
+ onClick: _cache[7] || (_cache[7] = ($event) => emit("welcome-click", currentWelcomeItem.value))
335
+ }, toDisplayString(currentWelcomeItem.value), 3)) : createCommentVNode("v-if", true)]),
336
+ _: 1
337
+ }, 8, ["name"])], 2)) : createCommentVNode("v-if", true)
338
+ ], 2)])], 2)) : createCommentVNode("v-if", true),
240
339
  createCommentVNode(" 用户上翻浏览时悬浮入口:一键回到最新消息 "),
241
340
  createVNode(Transition, { name: unref(cls).em("to-latest", "fade") }, {
242
341
  default: withCtx(() => [showToLatest.value ? (openBlock(), createElementBlock("button", {
@@ -247,7 +346,7 @@ const _sfc_main = /*@__PURE__*/ defineComponent({
247
346
  }, [createVNode(unref(UIcon), null, {
248
347
  default: withCtx(() => [createVNode(unref(ArrowDown))]),
249
348
  _: 1
250
- }), _cache[3] || (_cache[3] = createElementVNode("span", null, "最新消息", -1))], 2)) : createCommentVNode("v-if", true)]),
349
+ }), _cache[8] || (_cache[8] = createElementVNode("span", null, "最新消息", -1))], 2)) : createCommentVNode("v-if", true)]),
251
350
  _: 1
252
351
  }, 8, ["name"])
253
352
  ], 2);