@veltra/ai 2.0.0 → 2.1.1

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 (70) hide show
  1. package/dist/chat/index.d.ts +2 -2
  2. package/dist/chat/transports/openai.js +1 -1
  3. package/dist/chat/transports/openai.js.map +1 -1
  4. package/dist/chat/types.d.ts +37 -2
  5. package/dist/chat/use-chat.d.ts +23 -1
  6. package/dist/chat/use-chat.js +91 -10
  7. package/dist/chat/use-chat.js.map +1 -1
  8. package/dist/components/ai-chat/ai-chat.js +207 -46
  9. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  10. package/dist/components/ai-chat/chat-input.js +37 -12
  11. package/dist/components/ai-chat/chat-input.js.map +1 -1
  12. package/dist/components/ai-chat/di.js.map +1 -1
  13. package/dist/components/ai-chat/message-item.js +101 -23
  14. package/dist/components/ai-chat/message-item.js.map +1 -1
  15. package/dist/components/ai-chat/message-list.js +217 -32
  16. package/dist/components/ai-chat/message-list.js.map +1 -1
  17. package/dist/components/ai-chat/model-picker.js +69 -46
  18. package/dist/components/ai-chat/model-picker.js.map +1 -1
  19. package/dist/components/ai-chat/queue-list.js +70 -0
  20. package/dist/components/ai-chat/queue-list.js.map +1 -0
  21. package/dist/components/ai-chat/side-panel.js +96 -0
  22. package/dist/components/ai-chat/side-panel.js.map +1 -0
  23. package/dist/components/ai-chat/style.d.ts +4 -0
  24. package/dist/components/ai-chat/style.js +4 -0
  25. package/dist/components/ai-chat/style2.css +457 -74
  26. package/dist/components/ai-chat/tool-call.js +60 -26
  27. package/dist/components/ai-chat/tool-call.js.map +1 -1
  28. package/dist/components/ai-orb/ai-orb.js +94 -0
  29. package/dist/components/ai-orb/ai-orb.js.map +1 -0
  30. package/dist/components/ai-orb/orb-renderer.js +411 -0
  31. package/dist/components/ai-orb/orb-renderer.js.map +1 -0
  32. package/dist/components/ai-orb/style.d.ts +1 -0
  33. package/dist/components/ai-orb/style.js +1 -0
  34. package/dist/components/ai-orb/style2.css +23 -0
  35. package/dist/index.d.ts +4 -2
  36. package/dist/index.js +2 -1
  37. package/dist/style.js +1 -0
  38. package/dist/types/ai-chat.d.ts +21 -7
  39. package/dist/types/ai-orb.d.ts +27 -0
  40. package/dist/types/index.d.ts +2 -1
  41. package/package.json +5 -5
  42. package/src/chat/__test__/use-chat.test.ts +266 -13
  43. package/src/chat/transports/__test__/openai.test.ts +78 -0
  44. package/src/chat/transports/openai.ts +2 -2
  45. package/src/chat/types.ts +37 -1
  46. package/src/chat/use-chat.ts +120 -11
  47. package/src/components/ai-chat/__test__/ai-chat.test.ts +628 -12
  48. package/src/components/ai-chat/ai-chat.vue +227 -31
  49. package/src/components/ai-chat/chat-input.vue +41 -11
  50. package/src/components/ai-chat/di.ts +2 -0
  51. package/src/components/ai-chat/message-item.vue +98 -11
  52. package/src/components/ai-chat/message-list.vue +261 -21
  53. package/src/components/ai-chat/model-picker.vue +110 -59
  54. package/src/components/ai-chat/queue-list.vue +63 -0
  55. package/src/components/ai-chat/side-panel.vue +76 -0
  56. package/src/components/ai-chat/style.scss +527 -74
  57. package/src/components/ai-chat/style.ts +4 -0
  58. package/src/components/ai-chat/tool-call.vue +86 -42
  59. package/src/components/ai-orb/__test__/ai-orb.test.ts +251 -0
  60. package/src/components/ai-orb/ai-orb.vue +107 -0
  61. package/src/components/ai-orb/index.ts +1 -0
  62. package/src/components/ai-orb/orb-renderer.ts +505 -0
  63. package/src/components/ai-orb/style.scss +10 -0
  64. package/src/components/ai-orb/style.ts +1 -0
  65. package/src/components/index.ts +1 -0
  66. package/src/style.ts +1 -0
  67. package/src/tools/ask-question/__test__/ask-question.test.ts +1 -1
  68. package/src/types/ai-chat.ts +21 -6
  69. package/src/types/ai-orb.ts +27 -0
  70. package/src/types/index.ts +1 -0
@@ -1,42 +1,75 @@
1
1
  <template>
2
- <div :class="cls.b">
3
- <MessageList
4
- :messages="messages"
5
- :welcome="welcome"
6
- :renderer-props="rendererProps"
7
- @respond="respondToolCall"
8
- @regenerate="regenerate"
9
- >
10
- <template v-if="$slots.welcome" #welcome>
11
- <slot name="welcome" />
12
- </template>
13
- </MessageList>
14
-
15
- <ChatInput
16
- v-model:model="model"
17
- v-model:reasoning-level="reasoningLevel"
18
- :running="running"
19
- :models="models"
20
- :placeholder="placeholder"
21
- :accept="accept"
22
- :max-attachment-size="maxAttachmentSize"
23
- @send="send"
24
- @abort="abort"
2
+ <ULayout
3
+ ref="layoutRef"
4
+ :class="[cls.b, bem.is('resizing', resizing)]"
5
+ :cols="layoutCols"
6
+ rows="1fr"
7
+ :resizable="panelOpen"
8
+ :col-min-sizes="panelOpen ? [MAIN_MIN_WIDTH, PANEL_MIN_WIDTH] : undefined"
9
+ @resize-start="resizing = true"
10
+ @resize-end="resizing = false"
11
+ >
12
+ <div :class="[cls.e('main'), bem.is('empty', isEmpty)]">
13
+ <MessageList
14
+ :messages="messages"
15
+ :welcome="welcome"
16
+ :running="running"
17
+ :renderer-props="rendererProps"
18
+ @respond="respondToolCall"
19
+ @regenerate="regenerate"
20
+ @welcome-click="(text) => handleSend(text, [])"
21
+ >
22
+ <template v-if="$slots.welcome" #welcome>
23
+ <slot name="welcome" />
24
+ </template>
25
+ </MessageList>
26
+
27
+ <QueueList
28
+ :queue="queue"
29
+ @start-now="startQueued"
30
+ @edit="handleQueueEdit"
31
+ @remove="removeQueued"
32
+ />
33
+
34
+ <ChatInput
35
+ ref="chatInputRef"
36
+ v-model:model="model"
37
+ v-model:reasoning-level="reasoningLevel"
38
+ :running="running"
39
+ :models="models"
40
+ :placeholder="placeholder"
41
+ :accept="accept"
42
+ :max-attachment-size="maxAttachmentSize"
43
+ @send="handleSend"
44
+ @abort="abort"
45
+ />
46
+ </div>
47
+
48
+ <!-- 侧边面板(renderTo: 'panel' 的工具渲染区),与会话区宽度经 ULayout 拖拽手柄调节 -->
49
+ <SidePanel
50
+ v-if="activePanelCall"
51
+ ref="panelRef"
52
+ :tool-call="activePanelCall"
53
+ @close="closePanel"
25
54
  />
26
- </div>
55
+ </ULayout>
27
56
  </template>
28
57
 
29
58
  <script lang="ts" setup>
59
+ import { useResizeObserver } from '@veltra/compositions'
60
+ import { ULayout } from '@veltra/desktop'
30
61
  import { bem } from '@veltra/utils'
31
- import { computed, provide, useSlots } from 'vue'
62
+ import { computed, provide, ref, useSlots, useTemplateRef, watch } from 'vue'
32
63
 
33
- import type { ChatToolCall } from '../../chat/types'
64
+ import type { ChatAttachment, ChatToolCall } from '../../chat/types'
34
65
  import { useChat } from '../../chat/use-chat'
35
66
  import { createBuiltinTools } from '../../tools'
36
67
  import type { _AiChatExposed, AiChatEmits, AiChatProps } from '../../types'
37
68
  import ChatInput from './chat-input.vue'
38
69
  import { AiChatDIKey } from './di'
39
70
  import MessageList from './message-list.vue'
71
+ import QueueList from './queue-list.vue'
72
+ import SidePanel from './side-panel.vue'
40
73
 
41
74
  defineOptions({ name: 'UAiChat' })
42
75
 
@@ -55,7 +88,7 @@ const cls = bem('ai-chat')
55
88
 
56
89
  const slots = useSlots()
57
90
 
58
- /** 内置 + 用户工具(同名内置优先),供工具卡片解析 icon/label/render/autoCollapse */
91
+ /** 内置 + 用户工具(同名内置优先),供工具卡片解析 icon/label/render/renderTo/autoCollapse */
59
92
  const toolMap = computed(() => {
60
93
  const builtins = createBuiltinTools()
61
94
  const names = new Set(builtins.map((t) => t.name))
@@ -63,19 +96,182 @@ const toolMap = computed(() => {
63
96
  return Object.fromEntries(tools.map((t) => [t.name, t]))
64
97
  })
65
98
 
66
- provide(AiChatDIKey, { cls, slots, tools: toolMap })
67
-
68
99
  const {
69
100
  messages,
70
101
  model,
71
102
  reasoningLevel,
72
103
  running,
104
+ queue,
73
105
  send,
74
106
  abort,
75
107
  regenerate,
76
108
  clear,
77
- respondToolCall
109
+ respondToolCall,
110
+ enqueue,
111
+ startQueued,
112
+ removeQueued
78
113
  } = useChat({ props, emit })
79
114
 
80
- defineExpose<_AiChatExposed>({ send, abort, regenerate, clear })
115
+ /** 空会话(无可见消息):输入区垂直居中,欢迎区(orb)贴于输入区上方并与之左对齐 */
116
+ const isEmpty = computed(() => !messages.value.some((msg) => msg.role !== 'tool'))
117
+
118
+ /** 所有 renderTo: 'panel' 的工具调用(按消息顺序,面板内容的来源) */
119
+ const panelCalls = computed(() => {
120
+ const calls: ChatToolCall[] = []
121
+ for (const msg of messages.value) {
122
+ if (msg.role !== 'assistant' || !msg.toolCalls) continue
123
+ for (const call of msg.toolCalls) {
124
+ if (toolMap.value[call.name]?.renderTo === 'panel') calls.push(call)
125
+ }
126
+ }
127
+ return calls
128
+ })
129
+
130
+ /** 当前展示在右侧面板的工具调用 id;null 表示面板关闭 */
131
+ const activePanelCallId = ref<string | null>(null)
132
+
133
+ const activePanelCall = computed(() => {
134
+ if (!activePanelCallId.value) return null
135
+ return panelCalls.value.find((call) => call.id === activePanelCallId.value) ?? null
136
+ })
137
+
138
+ const panelOpen = computed(() => activePanelCall.value != null)
139
+
140
+ // 出现新的面板工具调用时自动打开并聚焦到它
141
+ watch(
142
+ () => panelCalls.value[panelCalls.value.length - 1]?.id,
143
+ (id) => {
144
+ if (id) activePanelCallId.value = id
145
+ }
146
+ )
147
+
148
+ // 聚焦的调用消失(清空消息 / 重新生成)时,回落到最近一次面板调用或关闭
149
+ watch(panelCalls, (calls) => {
150
+ const id = activePanelCallId.value
151
+ if (id && !calls.some((call) => call.id === id)) {
152
+ activePanelCallId.value = calls[calls.length - 1]?.id ?? null
153
+ }
154
+ })
155
+
156
+ /** 打开面板并聚焦到指定工具调用(工具卡片「查看面板」入口,经 DI 下发) */
157
+ const openPanel = (toolCallId: string) => {
158
+ if (panelCalls.value.some((call) => call.id === toolCallId)) {
159
+ activePanelCallId.value = toolCallId
160
+ }
161
+ }
162
+
163
+ const closePanel = () => {
164
+ activePanelCallId.value = null
165
+ }
166
+
167
+ provide(AiChatDIKey, { cls, slots, tools: toolMap, openPanel })
168
+
169
+ /**
170
+ * 面板宽度:经 ULayout 列轨施加(cols = 1fr + 面板宽度),
171
+ * 拖拽手柄由 ULayout 提供(colMinSizes 约束会话区 / 面板最小宽度)。
172
+ */
173
+ const PANEL_MIN_WIDTH = 320
174
+ const MAIN_MIN_WIDTH = 360
175
+ /** 面板打开时默认给会话区留的宽度(即面板默认尽可能大) */
176
+ const MAIN_DEFAULT_WIDTH = 860
177
+
178
+ const layoutRef = useTemplateRef<InstanceType<typeof ULayout>>('layoutRef')
179
+ const panelRef = useTemplateRef<InstanceType<typeof SidePanel>>('panelRef')
180
+ const panelWidth = ref(PANEL_MIN_WIDTH)
181
+ const resizing = ref(false)
182
+
183
+ const layoutCols = computed(() => {
184
+ return panelOpen.value ? ['1fr', `${panelWidth.value}px`] : ['1fr']
185
+ })
186
+
187
+ const containerWidth = () => (layoutRef.value?.$el as HTMLElement | undefined)?.clientWidth ?? 0
188
+
189
+ /** 面板默认宽度:容器宽减去会话区默认保留宽度(缺省给会话区留 MAIN_DEFAULT_WIDTH) */
190
+ const defaultPanelWidth = () => {
191
+ const width = containerWidth()
192
+ return width ? width - MAIN_DEFAULT_WIDTH : 420
193
+ }
194
+
195
+ /** 钳位面板宽度在 [PANEL_MIN_WIDTH, 容器宽 - MAIN_MIN_WIDTH] 区间 */
196
+ const clampPanelWidth = (width: number) => {
197
+ const width0 = containerWidth()
198
+ const maxWidth = width0
199
+ ? Math.max(PANEL_MIN_WIDTH, width0 - MAIN_MIN_WIDTH)
200
+ : Number.MAX_SAFE_INTEGER
201
+ return Math.min(Math.max(width, PANEL_MIN_WIDTH), maxWidth)
202
+ }
203
+
204
+ // 聚焦到指定了 panelWidth 的工具调用时应用其宽度;面板从关闭到打开且工具未指定宽度时
205
+ // 应用默认宽(会话区留 860px);面板已打开时切换聚焦保持当前宽度(含用户拖拽结果)
206
+ watch(activePanelCall, (call, prevCall) => {
207
+ if (!call) return
208
+ const toolWidth = toolMap.value[call.name]?.panelWidth
209
+ const width = toolWidth ?? (prevCall ? undefined : defaultPanelWidth())
210
+ if (typeof width !== 'number') return
211
+ panelWidth.value = clampPanelWidth(width)
212
+ })
213
+
214
+ // 跟踪面板实际宽度(含用户拖拽结果):面板关闭重开、切换到未指定宽度的工具时保持
215
+ useResizeObserver({
216
+ targets: computed(() => (panelRef.value?.$el as HTMLElement | undefined) ?? undefined),
217
+ onResize(entries) {
218
+ // borderBoxSize 随通知携带(含边框、零布局开销),不读 offsetWidth/clientWidth
219
+ const inlineSize = entries[0]?.borderBoxSize?.[0]?.inlineSize
220
+ if (!inlineSize) return
221
+ const width = Math.round(inlineSize)
222
+ // 滞回:忽略亚像素/边框级的微小偏差(< 2px),阻断任何「观测→回写→再观测」的微缩反馈回路
223
+ if (Math.abs(width - panelWidth.value) < 2) return
224
+ panelWidth.value = width
225
+ }
226
+ })
227
+
228
+ const chatInputRef = useTemplateRef<InstanceType<typeof ChatInput>>('chatInputRef')
229
+
230
+ /**
231
+ * 编辑中的队列项原后继 id(编辑态标记):
232
+ * undefined 表示无编辑;null 表示原位置在队尾;否则重新提交时插回该后继之前,
233
+ * 使前后项顺序不受编辑影响(前项已执行则该项自然成为最前待执行项)。
234
+ */
235
+ const editingSuccessor = ref<string | null | undefined>(undefined)
236
+
237
+ /** 编辑队列项:取回输入框重新编辑;已有未完成编辑时先按锚点还原,避免内容丢失 */
238
+ const handleQueueEdit = (id: string) => {
239
+ restoreEditing()
240
+
241
+ const index = queue.value.findIndex((item) => item.id === id)
242
+ if (index === -1) return
243
+ editingSuccessor.value = queue.value[index + 1]?.id ?? null
244
+
245
+ const item = removeQueued(id)
246
+ if (item) chatInputRef.value?.setContent(item.content)
247
+ }
248
+
249
+ /** 将输入框中未提交的编辑内容按原锚点插回队列 */
250
+ const restoreEditing = () => {
251
+ if (editingSuccessor.value === undefined) return
252
+ const content = chatInputRef.value?.getContent().trim()
253
+ if (content) enqueue(content, undefined, editingSuccessor.value ?? undefined)
254
+ editingSuccessor.value = undefined
255
+ }
256
+
257
+ const handleSend = (content: string, attachments: ChatAttachment[]) => {
258
+ // 编辑态提交:插回原锚点位置(会话进行中则留在队列,空闲时由 enqueue 自动消耗队首)
259
+ if (editingSuccessor.value !== undefined) {
260
+ enqueue(content, attachments, editingSuccessor.value ?? undefined)
261
+ editingSuccessor.value = undefined
262
+ return
263
+ }
264
+ send(content, attachments)
265
+ }
266
+
267
+ defineExpose<_AiChatExposed>({
268
+ send,
269
+ abort,
270
+ regenerate,
271
+ clear,
272
+ queue,
273
+ startQueued,
274
+ removeQueued,
275
+ enqueue
276
+ })
81
277
  </script>
@@ -17,7 +17,7 @@
17
17
  ref="textareaRef"
18
18
  v-model="text"
19
19
  :class="cls.e('input')"
20
- :placeholder="placeholder ?? '输入消息,Enter 发送,Shift + Enter 换行'"
20
+ :placeholder="placeholderText"
21
21
  rows="1"
22
22
  @keydown.enter.exact.prevent="handleSend"
23
23
  @input="autoResize"
@@ -32,7 +32,7 @@
32
32
  </UFilePicker>
33
33
  </div>
34
34
 
35
- <!-- 右簇:模型/推理选择 → 发送/停止,贴近发送按钮 -->
35
+ <!-- 右簇:模型/推理选择 → 发送或停止(互斥) -->
36
36
  <div :class="cls.e('input-toolbar-right')">
37
37
  <ModelPicker
38
38
  v-if="models?.length"
@@ -41,19 +41,28 @@
41
41
  :models="models"
42
42
  />
43
43
 
44
- <UButton v-if="running" size="small" type="danger" circle @click="emit('abort')">
44
+ <UButton
45
+ v-if="showStop"
46
+ key="stop"
47
+ size="small"
48
+ type="danger"
49
+ circle
50
+ title="停止生成"
51
+ @click="emit('abort')"
52
+ >
45
53
  <span :class="cls.e('input-stop')" />
46
54
  </UButton>
47
55
  <UButton
48
56
  v-else
57
+ key="send"
49
58
  size="small"
50
59
  type="primary"
51
60
  circle
52
- :disabled="!canSend"
61
+ :disabled="!hasContent"
53
62
  :icon="Up"
63
+ :title="running ? '加入待发送队列' : undefined"
54
64
  @click="handleSend"
55
- >
56
- </UButton>
65
+ />
57
66
  </div>
58
67
  </div>
59
68
  </div>
@@ -63,7 +72,7 @@
63
72
  import { UButton, UFilePicker, UIcon } from '@veltra/desktop'
64
73
  import { Attach, Close, Up } from '@veltra/icons/normal'
65
74
  import { bem } from '@veltra/utils'
66
- import { computed, inject, ref, shallowRef } from 'vue'
75
+ import { computed, inject, nextTick, ref, shallowRef } from 'vue'
67
76
 
68
77
  import type { ChatAttachment } from '../../chat/types'
69
78
  import type { ChatModelOption } from '../../providers'
@@ -73,7 +82,7 @@ import ModelPicker from './model-picker.vue'
73
82
  defineOptions({ name: 'UAiChatInput' })
74
83
 
75
84
  const props = defineProps<{
76
- /** 是否生成中(显示停止按钮) */
85
+ /** 是否生成中(空输入显示停止,有内容则发送入队) */
77
86
  running: boolean
78
87
  /** 可选模型列表;有值则显示选择器 */
79
88
  models?: ChatModelOption[]
@@ -100,8 +109,15 @@ const text = ref('')
100
109
  const attachments = ref<ChatAttachment[]>([])
101
110
  const textareaRef = shallowRef<HTMLTextAreaElement>()
102
111
 
103
- const canSend = computed(() => {
104
- return !props.running && (!!text.value.trim() || attachments.value.length > 0)
112
+ const hasContent = computed(() => !!text.value.trim() || attachments.value.length > 0)
113
+
114
+ /** 生成中且输入为空时显示停止;有内容则显示发送(入队) */
115
+ const showStop = computed(() => props.running && !hasContent.value)
116
+
117
+ /** 生成中提示用户消息将进入队列 */
118
+ const placeholderText = computed(() => {
119
+ if (props.running) return '会话进行中,发送的消息将进入待发送队列'
120
+ return props.placeholder ?? '输入消息,Enter 发送,Shift + Enter 换行'
105
121
  })
106
122
 
107
123
  /** 多行自适应高度,上限 160px */
@@ -113,13 +129,27 @@ const autoResize = () => {
113
129
  }
114
130
 
115
131
  const handleSend = () => {
116
- if (!canSend.value) return
132
+ if (!hasContent.value) return
117
133
  emit('send', text.value.trim(), attachments.value)
118
134
  text.value = ''
119
135
  attachments.value = []
120
136
  autoResize()
121
137
  }
122
138
 
139
+ /** 取回内容到输入框(队列编辑场景),并聚焦 */
140
+ const setContent = (content: string) => {
141
+ text.value = content
142
+ void nextTick(() => {
143
+ autoResize()
144
+ textareaRef.value?.focus()
145
+ })
146
+ }
147
+
148
+ /** 读取当前输入框内容 */
149
+ const getContent = () => text.value
150
+
151
+ defineExpose({ setContent, getContent })
152
+
123
153
  /** 将拾取的文件转为 dataUrl 附件(超限忽略) */
124
154
  const handlePick = (files: File[]) => {
125
155
  const maxSize = props.maxAttachmentSize ?? 10 * 1024 * 1024
@@ -10,6 +10,8 @@ export interface AiChatContext {
10
10
  slots: Slots
11
11
  /** 按 name 索引的工具定义(解析 icon/label/render/autoCollapse) */
12
12
  tools: ComputedRef<Record<string, ChatTool | undefined>>
13
+ /** 打开右侧面板并聚焦到指定工具调用(renderTo: 'panel' 工具的「查看面板」入口) */
14
+ openPanel: (toolCallId: string) => void
13
15
  }
14
16
 
15
17
  export const AiChatDIKey: InjectionKey<AiChatContext> = Symbol('AiChatDIKey')
@@ -24,16 +24,26 @@
24
24
  v-if="message.reasoning"
25
25
  :class="[cls.e('reasoning'), bem.is('expanded', reasoningExpanded)]"
26
26
  >
27
- <div :class="cls.e('reasoning-header')" @click="reasoningExpanded = !reasoningExpanded">
28
- <UIcon :class="[cls.e('reasoning-icon'), isThinking && cls.is('loading')]">
27
+ <div :class="cls.e('reasoning-header')" @click="toggleReasoning">
28
+ <UIcon :class="[cls.e('reasoning-icon'), bem.is('loading', isThinking)]">
29
29
  <Loading v-if="isThinking" />
30
30
  <InfoCircle v-else />
31
31
  </UIcon>
32
- <span>{{ isThinking ? '思考中…' : '思考过程' }}</span>
33
- <UIcon :class="cls.e('reasoning-chevron')"><ArrowDown /></UIcon>
32
+ <span :class="[cls.e('reasoning-title'), isThinking && 'u-shine']">
33
+ {{ isThinking ? '思考中…' : '思考过程' }}
34
+ </span>
35
+ <UIcon :class="cls.e('reasoning-chevron')"><ArrowRight /></UIcon>
34
36
  </div>
35
- <div v-show="reasoningExpanded" :class="cls.e('reasoning-content')">
36
- {{ message.reasoning }}
37
+ <!-- 折叠时完全卸载内容 DOM:思考文本可能很长,常驻挂载浪费内存与渲染成本 -->
38
+ <div v-if="reasoningExpanded" :class="cls.e('reasoning-content')">
39
+ <UScroll
40
+ ref="reasoningScrollRef"
41
+ container-style="max-height: 220px"
42
+ @scroll="handleReasoningScroll"
43
+ @wheel.passive="handleReasoningWheel"
44
+ >
45
+ <div :class="cls.e('reasoning-text')">{{ message.reasoning }}</div>
46
+ </UScroll>
37
47
  </div>
38
48
  </div>
39
49
 
@@ -59,19 +69,38 @@
59
69
  已停止生成
60
70
  </div>
61
71
 
62
- <div v-if="showActions" :class="cls.e('message-actions')" @click="emit('regenerate')">
63
- <UIcon title="重新生成"><Refresh /></UIcon>
72
+ <div v-if="showActions" :class="cls.e('message-actions')">
73
+ <UButton
74
+ size="small"
75
+ plain
76
+ circle
77
+ :type="copied ? 'success' : 'primary'"
78
+ :icon="copied ? CircleCheck : Copy"
79
+ :disabled="!canCopy"
80
+ :title="copied ? '已复制' : '复制'"
81
+ @click="handleCopy"
82
+ />
83
+ <UButton
84
+ size="small"
85
+ plain
86
+ circle
87
+ type="primary"
88
+ :icon="Refresh"
89
+ title="重新生成"
90
+ @click="emit('regenerate')"
91
+ />
64
92
  </div>
65
93
  </template>
66
94
  </div>
67
95
  </template>
68
96
 
69
97
  <script lang="ts" setup>
70
- import { UIcon } from '@veltra/desktop'
71
- import { ArrowDown, InfoCircle, Loading, Refresh } from '@veltra/icons/normal'
98
+ import { message as toast, UButton, UIcon, UScroll } from '@veltra/desktop'
99
+ import type { ScrollPosition } from '@veltra/desktop'
100
+ import { ArrowRight, CircleCheck, Copy, InfoCircle, Loading, Refresh } from '@veltra/icons/normal'
72
101
  import { bem } from '@veltra/utils'
73
102
  import MarkdownRender from 'markstream-vue'
74
- import { computed, inject, ref, watch } from 'vue'
103
+ import { computed, inject, nextTick, onBeforeUnmount, ref, useTemplateRef, watch } from 'vue'
75
104
 
76
105
  import type { ChatMessage } from '../../chat/types'
77
106
  import { AiChatDIKey } from './di'
@@ -101,20 +130,78 @@ const isThinking = computed(() => isStreaming.value && !props.message.content)
101
130
 
102
131
  const reasoningExpanded = ref(true)
103
132
 
133
+ /** 思考区吸附底部:用户在思考区内上滚时取消吸附,回到底部后恢复 */
134
+ const reasoningStick = ref(true)
135
+
136
+ /** 思考内容滚动区:流式输出时吸附底部,始终展示最新思考 */
137
+ const reasoningScrollRef = useTemplateRef<InstanceType<typeof UScroll>>('reasoningScrollRef')
138
+
139
+ const toggleReasoning = async () => {
140
+ reasoningExpanded.value = !reasoningExpanded.value
141
+ if (!reasoningExpanded.value) return
142
+ // 重新展开时恢复吸附并定位到最新思考
143
+ reasoningStick.value = true
144
+ await nextTick()
145
+ reasoningScrollRef.value?.scrollTo({ y: Number.MAX_SAFE_INTEGER })
146
+ }
147
+
148
+ const handleReasoningWheel = (e: WheelEvent) => {
149
+ if (e.deltaY < 0) reasoningStick.value = false
150
+ }
151
+
152
+ const handleReasoningScroll = (position: Required<ScrollPosition>) => {
153
+ if (position.sh - position.ch - position.y < 8) reasoningStick.value = true
154
+ }
155
+
104
156
  // 流式结束后自动折叠思考过程
105
157
  watch(
106
158
  () => props.message.status,
107
159
  (status) => {
108
160
  if (status === 'streaming') {
109
161
  reasoningExpanded.value = true
162
+ reasoningStick.value = true
110
163
  } else if (status) {
111
164
  reasoningExpanded.value = false
112
165
  }
113
166
  }
114
167
  )
115
168
 
169
+ watch(
170
+ () => props.message.reasoning,
171
+ async () => {
172
+ if (!isThinking.value || !reasoningExpanded.value || !reasoningStick.value) return
173
+ await nextTick()
174
+ // 等待 DOM 更新期间用户可能已上滚,滚动前重新确认吸附状态
175
+ if (!reasoningStick.value) return
176
+ reasoningScrollRef.value?.scrollTo({ y: Number.MAX_SAFE_INTEGER })
177
+ }
178
+ )
179
+
116
180
  const showActions = computed(() => {
117
181
  if (props.message.role !== 'assistant' || !props.isLast) return false
118
182
  return ['done', 'error', 'aborted'].includes(props.message.status ?? '')
119
183
  })
184
+
185
+ const canCopy = computed(() => !!props.message.content?.trim())
186
+
187
+ const copied = ref(false)
188
+ let copyTimer: ReturnType<typeof setTimeout> | undefined
189
+
190
+ const handleCopy = async () => {
191
+ const text = props.message.content?.trim()
192
+ if (!text) return
193
+ try {
194
+ await navigator.clipboard.writeText(text)
195
+ copied.value = true
196
+ toast.success('已复制到剪贴板')
197
+ clearTimeout(copyTimer)
198
+ copyTimer = setTimeout(() => {
199
+ copied.value = false
200
+ }, 2000)
201
+ } catch {
202
+ toast.error('复制失败,请检查浏览器权限')
203
+ }
204
+ }
205
+
206
+ onBeforeUnmount(() => clearTimeout(copyTimer))
120
207
  </script>