@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
@@ -7,46 +7,79 @@
7
7
  @scroll="handleScroll"
8
8
  @wheel.passive="handleWheel"
9
9
  >
10
- <div v-if="!visibleMessages.length" :class="cls.e('welcome')">
11
- <slot name="welcome">
12
- <div :class="cls.e('welcome-inner')">
13
- <UAiOrb :size="WELCOME_ORB_SIZE" @click="cycleWelcome" />
14
- <!-- 快捷提问逐条展示在球右侧并自动轮换:点文案发送,点球立即换下一条。
15
- 固定锚点布局:球位置钉死不动,气泡只向右延展,避免轮换时水平瞬移 -->
16
- <div v-if="welcomeItems.length" :class="cls.e('welcome-item-wrap')">
17
- <Transition :name="cls.em('welcome', 'swap')" mode="out-in">
18
- <button
19
- v-if="currentWelcomeItem"
20
- :key="welcomeIndex"
21
- type="button"
22
- :class="cls.e('welcome-item')"
23
- @click="emit('welcome-click', currentWelcomeItem)"
24
- >
25
- {{ currentWelcomeItem }}
26
- </button>
27
- </Transition>
28
- </div>
29
- </div>
30
- </slot>
31
- </div>
32
-
33
- <MessageItem
34
- v-for="(msg, index) in visibleMessages"
35
- :key="msg.id"
36
- :message="msg"
37
- :is-last="index === visibleMessages.length - 1"
38
- :renderer-props="rendererProps"
39
- @respond="(id, approved) => emit('respond', id, approved)"
40
- @regenerate="emit('regenerate')"
41
- />
42
-
43
- <!-- 工作状态统一指示:生成中在列表末尾展示活体球 + 文案;结束 / 失败停留片刻再隐藏 -->
44
- <div v-if="running || finishing" :class="cls.e('working')">
45
- <UAiOrb ref="workingOrb" :size="WORKING_ORB_SIZE" :status="workingOrbStatus" />
10
+ <template v-for="turn in turns" :key="turn.key">
11
+ <MessageItem
12
+ v-if="turn.userMsg"
13
+ :message="turn.userMsg"
14
+ :is-last="false"
15
+ :renderer-props="rendererProps"
16
+ @respond="(id, approved) => emit('respond', id, approved)"
17
+ @regenerate="emit('regenerate')"
18
+ />
19
+
20
+ <!-- 最终答案开始输出后,本轮之前的思考/工具调用过程收进「已完成」折叠块 -->
21
+ <TurnProcess
22
+ v-if="turn.processCollapsed"
23
+ :messages="turn.processMsgs"
24
+ :renderer-props="rendererProps"
25
+ @respond="(id, approved) => emit('respond', id, approved)"
26
+ />
27
+ <template v-else>
28
+ <MessageItem
29
+ v-for="msg in turn.processMsgs"
30
+ :key="msg.id"
31
+ :message="msg"
32
+ :is-last="false"
33
+ :renderer-props="rendererProps"
34
+ @respond="(id, approved) => emit('respond', id, approved)"
35
+ @regenerate="emit('regenerate')"
36
+ />
37
+ </template>
38
+
39
+ <MessageItem
40
+ v-if="turn.answerMsg"
41
+ :message="turn.answerMsg"
42
+ :is-last="turn.isLast"
43
+ :renderer-props="rendererProps"
44
+ @respond="(id, approved) => emit('respond', id, approved)"
45
+ @regenerate="emit('regenerate')"
46
+ />
47
+ </template>
48
+
49
+ <!-- 工作中:活体球从输入框上方跳到最新会话下面;结束停留后再跳回 -->
50
+ <div v-if="showWorking" :class="[cls.e('working'), bem.is('leaving', workingLeaving)]">
51
+ <UAiOrb ref="workingOrb" :size="ORB_SIZE" :status="workingOrbStatus" />
46
52
  <span v-if="running" :class="[cls.e('working-text'), 'u-shine']">工作中…</span>
47
53
  </div>
48
54
  </UScroll>
49
55
 
56
+ <!-- 空闲:始终钉在滚动容器外、输入框上方;工作开始时缩小离开、结束后放大回来 -->
57
+ <div
58
+ v-if="showIdleWelcome"
59
+ :class="[cls.e('welcome'), bem.is('leaving', idleLeaving), bem.is('enter', idleEnter)]"
60
+ >
61
+ <slot name="welcome">
62
+ <div :class="cls.e('welcome-inner')">
63
+ <UAiOrb :size="ORB_SIZE" @click="cycleWelcome" />
64
+ <!-- 快捷提问逐条展示在球右侧并自动轮换:点文案发送,点球立即换下一条。
65
+ 固定锚点布局:球位置钉死不动,气泡只向右延展,避免轮换时水平瞬移 -->
66
+ <div v-if="welcomeItems.length" :class="cls.e('welcome-item-wrap')">
67
+ <Transition :name="cls.em('welcome', 'swap')" mode="out-in">
68
+ <button
69
+ v-if="currentWelcomeItem"
70
+ :key="welcomeIndex"
71
+ type="button"
72
+ :class="cls.e('welcome-item')"
73
+ @click="emit('welcome-click', currentWelcomeItem)"
74
+ >
75
+ {{ currentWelcomeItem }}
76
+ </button>
77
+ </Transition>
78
+ </div>
79
+ </div>
80
+ </slot>
81
+ </div>
82
+
50
83
  <!-- 用户上翻浏览时悬浮入口:一键回到最新消息 -->
51
84
  <Transition :name="cls.em('to-latest', 'fade')">
52
85
  <button v-if="showToLatest" type="button" :class="cls.e('to-latest')" @click="scrollToLatest">
@@ -78,19 +111,20 @@ import type { AiOrbExposed, AiOrbReaction, AiOrbStatus } from '../../types/ai-or
78
111
  import UAiOrb from '../ai-orb/ai-orb.vue'
79
112
  import { AiChatDIKey } from './di'
80
113
  import MessageItem from './message-item.vue'
114
+ import TurnProcess from './turn-process.vue'
81
115
 
82
116
  defineOptions({ name: 'UAiChatMessageList' })
83
117
 
84
- /** 欢迎区活体球直径(px) */
85
- const WELCOME_ORB_SIZE = 64
86
- /** 工作指示活体球直径(px) */
87
- const WORKING_ORB_SIZE = 48
88
- /** 生成结束 / 失败后工作球停留再隐藏 */
118
+ /** 空闲欢迎 / 工作指示共用活体球直径(px) */
119
+ const ORB_SIZE = 48
120
+ /** 生成结束 / 失败后工作球停留再跳回输入框上方 */
89
121
  const WORKING_LINGER_MS = 2500
122
+ /** 两处活体球对向缩放的时长(与样式中的 jump 过渡一致) */
123
+ const JUMP_MS = 380
90
124
 
91
125
  const props = defineProps<{
92
126
  messages: ChatMessage[]
93
- /** 空状态欢迎项;字符串按单项处理 */
127
+ /** 空闲时输入框上方的快捷提问;字符串按单项处理 */
94
128
  welcome?: string | string[]
95
129
  /** 是否生成中(控制底部工作状态指示) */
96
130
  running?: boolean
@@ -106,7 +140,7 @@ const emit = defineEmits<{
106
140
  }>()
107
141
 
108
142
  defineSlots<{
109
- /** 空状态欢迎区插槽 */
143
+ /** 空闲欢迎区插槽(输入框上方;工作中跳到列表末尾) */
110
144
  welcome(): any
111
145
  }>()
112
146
 
@@ -116,6 +150,56 @@ const cls = di?.cls ?? bem('ai-chat')
116
150
  /** tool 消息不单独渲染,其内容体现在工具卡片的结果区 */
117
151
  const visibleMessages = computed(() => props.messages.filter((msg) => msg.role !== 'tool'))
118
152
 
153
+ /** 一轮对话:以 user 消息为界,含本轮全部 assistant 消息(每次工具循环产生一条) */
154
+ interface ChatTurn {
155
+ /** 轮次 key(取用户消息或首条 assistant 消息 id) */
156
+ key: string
157
+ userMsg?: ChatMessage
158
+ /** 最终答案之前的过程消息(思考 + 中间文本 + 工具卡片) */
159
+ processMsgs: ChatMessage[]
160
+ /** 本轮最终答案(最后一条 assistant 消息) */
161
+ answerMsg?: ChatMessage
162
+ /** 过程消息是否收进「已完成」折叠块 */
163
+ processCollapsed: boolean
164
+ /** answerMsg 是否为全局最后一条可见消息 */
165
+ isLast: boolean
166
+ }
167
+
168
+ const isFinalStatus = (msg?: ChatMessage) =>
169
+ msg?.status === 'done' || msg?.status === 'error' || msg?.status === 'aborted'
170
+
171
+ const turns = computed<ChatTurn[]>(() => {
172
+ const grouped: { key: string; userMsg?: ChatMessage; assistants: ChatMessage[] }[] = []
173
+ for (const msg of visibleMessages.value) {
174
+ if (msg.role === 'user') {
175
+ grouped.push({ key: msg.id, userMsg: msg, assistants: [] })
176
+ continue
177
+ }
178
+ const last = grouped[grouped.length - 1]
179
+ // 兼容 assistant 开头的历史会话:归入无用户消息的轮次
180
+ if (last) last.assistants.push(msg)
181
+ else grouped.push({ key: msg.id, assistants: [msg] })
182
+ }
183
+ return grouped.map((turn, index) => {
184
+ const processMsgs = turn.assistants.slice(0, -1)
185
+ const answerMsg = turn.assistants[turn.assistants.length - 1]
186
+ const isLastTurn = index === grouped.length - 1
187
+ // 该轮结束(非最后一轮 / 答案进入终态)或最终答案开始输出时折叠过程。
188
+ // content 只增不减,折叠后不会回弹;regenerate 删除本轮 assistant 重跑,条件自然复位
189
+ const processCollapsed =
190
+ processMsgs.length > 0 &&
191
+ (!isLastTurn || !!answerMsg?.content || (!props.running && isFinalStatus(answerMsg)))
192
+ return {
193
+ key: turn.key,
194
+ userMsg: turn.userMsg,
195
+ processMsgs,
196
+ answerMsg,
197
+ processCollapsed,
198
+ isLast: isLastTurn && !!answerMsg
199
+ }
200
+ })
201
+ })
202
+
119
203
  const welcomeItems = computed(() => {
120
204
  const welcome = props.welcome
121
205
  if (!welcome) return []
@@ -154,17 +238,27 @@ const cycleWelcome = () => {
154
238
  startWelcomeRotation()
155
239
  }
156
240
 
157
- // 欢迎区展示期间自动轮换;出现消息后停止
158
- watch(
159
- () => [welcomeItems.value.length, visibleMessages.value.length] as const,
160
- () => {
161
- if (visibleMessages.value.length > 0) stopWelcomeRotation()
162
- else startWelcomeRotation()
163
- },
164
- { immediate: true }
165
- )
241
+ /** 回答完毕 / 失败后工作球短暂停留,播完表情再跳回输入框上方 */
242
+ const finishing = ref(false)
243
+ /** 列表中的球正在缩小离开 */
244
+ const workingLeaving = ref(false)
245
+ /** 输入框上方的球正在缩小离开 */
246
+ const idleLeaving = ref(false)
247
+ /** 输入框上方的球从工作位放大回来(首屏出现不播) */
248
+ const idleEnter = ref(false)
166
249
 
167
- onBeforeUnmount(stopWelcomeRotation)
250
+ let finishTimer: ReturnType<typeof setTimeout> | undefined
251
+ let jumpTimer: ReturnType<typeof setTimeout> | undefined
252
+
253
+ const clearJumpTimers = () => {
254
+ clearTimeout(finishTimer)
255
+ clearTimeout(jumpTimer)
256
+ }
257
+
258
+ /** 列表末尾的工作球:生成中 / 收尾停留 / 跳回过渡 */
259
+ const showWorking = computed(() => !!(props.running || finishing.value || workingLeaving.value))
260
+ /** 输入框上方的空闲欢迎:含工作开始时的缩小离开帧 */
261
+ const showIdleWelcome = computed(() => (!props.running && !finishing.value) || idleLeaving.value)
168
262
 
169
263
  const workingOrbRef = useTemplateRef<AiOrbExposed>('workingOrb')
170
264
 
@@ -191,15 +285,6 @@ watch(toolErrorIds, (ids) => {
191
285
  }
192
286
  })
193
287
 
194
- /** 回答完毕 / 失败后工作球短暂停留,播完表情再隐藏 */
195
- const finishing = ref(false)
196
- let finishTimer: ReturnType<typeof setTimeout> | undefined
197
-
198
- const clearFinishing = () => {
199
- clearTimeout(finishTimer)
200
- finishing.value = false
201
- }
202
-
203
288
  /** 工作球状态:收尾停留 → idle;正文输出中 → speaking;其余生成阶段 → thinking */
204
289
  const workingOrbStatus = computed<AiOrbStatus>(() => {
205
290
  if (finishing.value) return 'idle'
@@ -214,13 +299,26 @@ watch(
214
299
  () => props.running,
215
300
  (running, prev) => {
216
301
  if (running) {
217
- // 新一轮生成开始:立即结束收尾停留
218
- clearFinishing()
302
+ // 工作开始:立即从输入框上方跳到列表,无停留
303
+ clearJumpTimers()
304
+ const idleWasVisible = !prev && !finishing.value && !workingLeaving.value
305
+ finishing.value = false
306
+ workingLeaving.value = false
307
+ idleEnter.value = false
308
+ if (idleWasVisible) {
309
+ idleLeaving.value = true
310
+ jumpTimer = setTimeout(() => {
311
+ idleLeaving.value = false
312
+ }, JUMP_MS)
313
+ } else {
314
+ idleLeaving.value = false
315
+ }
219
316
  return
220
317
  }
221
318
  if (!prev) return
222
319
 
223
320
  finishing.value = true
321
+ idleLeaving.value = false
224
322
  const last = visibleMessages.value[visibleMessages.value.length - 1]
225
323
  let reaction: AiOrbReaction | undefined
226
324
  if (last?.role === 'assistant') {
@@ -230,11 +328,33 @@ watch(
230
328
  if (reaction) {
231
329
  nextTick(() => workingOrbRef.value?.react(reaction))
232
330
  }
233
- finishTimer = setTimeout(clearFinishing, WORKING_LINGER_MS)
331
+ // 停留结束 → 列表中缩小、输入框上方放大
332
+ finishTimer = setTimeout(() => {
333
+ finishing.value = false
334
+ workingLeaving.value = true
335
+ idleEnter.value = true
336
+ jumpTimer = setTimeout(() => {
337
+ workingLeaving.value = false
338
+ idleEnter.value = false
339
+ }, JUMP_MS)
340
+ }, WORKING_LINGER_MS)
234
341
  }
235
342
  )
236
343
 
237
- onBeforeUnmount(clearFinishing)
344
+ // 空闲欢迎区展示期间自动轮换;跳到工作位后停止
345
+ watch(
346
+ () => [welcomeItems.value.length, showIdleWelcome.value] as const,
347
+ () => {
348
+ if (showIdleWelcome.value) startWelcomeRotation()
349
+ else stopWelcomeRotation()
350
+ },
351
+ { immediate: true }
352
+ )
353
+
354
+ onBeforeUnmount(() => {
355
+ stopWelcomeRotation()
356
+ clearJumpTimers()
357
+ })
238
358
 
239
359
  const scrollRef = shallowRef<{ scrollTo: (position: ScrollPosition) => void }>()
240
360
  /** 是否吸附底部(用户上翻浏览历史时取消吸附) */
@@ -22,7 +22,7 @@ $root-name: ai-chat;
22
22
  padding: 0 fn.use-var(gap, default);
23
23
 
24
24
  // 空会话:列表区与末尾占位各分一半剩余空间,输入区垂直居中,
25
- // 欢迎区(orb + 快捷提问)贴于输入区上方并与之左对齐;有消息后自动回落到底部
25
+ // 欢迎区钉在滚动容器外、贴于输入框上方;有消息后自动回落到底部
26
26
  @include m.is(empty) {
27
27
  &::after {
28
28
  content: '';
@@ -78,6 +78,19 @@ $root-name: ai-chat;
78
78
  }
79
79
  }
80
80
 
81
+ // 活体球在输入框上方 ↔ 列表末尾对向缩放(时长与 JUMP_MS 一致)
82
+ @keyframes u-ai-chat-orb-jump-in {
83
+ from {
84
+ opacity: 0;
85
+ transform: scale(0);
86
+ }
87
+
88
+ to {
89
+ opacity: 1;
90
+ transform: scale(1);
91
+ }
92
+ }
93
+
81
94
  // 面板头部:无下分割线,靠留白与卡片边界区分内容区
82
95
  @include m.e(panel-header) {
83
96
  flex: none;
@@ -178,22 +191,43 @@ $root-name: ai-chat;
178
191
  @include m.e(list) {
179
192
  flex: 1;
180
193
  min-height: 0;
181
-
182
- // 内容不足一屏时撑满滚动视口并按列排布,空会话欢迎区才能占满整列做底部对齐
183
- .u-scroll__content {
184
- min-height: 100%;
185
- display: flex;
186
- flex-direction: column;
187
- }
188
194
  }
189
195
 
190
- // 欢迎区仅在空会话渲染:占满整列并底部对齐,使其贴在居中的输入区上方
196
+ // 空闲欢迎:钉在滚动容器外、输入框上方;高度锁 48px(球 + 快捷提问)
191
197
  @include m.e(welcome) {
192
- flex: 1;
193
- @include m.flex(flex, center, flex-end);
198
+ flex-shrink: 0;
199
+ width: 100%;
200
+ height: 48px;
201
+ @include m.flex(flex, flex-start, center);
194
202
  color: fn.use-var(text-color, assist);
195
- padding: fn.use-var(gap, default) 0;
196
- text-align: center;
203
+ padding: 0 fn.use-var(gap, default);
204
+
205
+ .u-ai-orb {
206
+ transform-origin: center;
207
+ }
208
+
209
+ @include m.is(enter) {
210
+ .u-ai-orb {
211
+ animation: u-ai-chat-orb-jump-in 0.38s ease both;
212
+ }
213
+ }
214
+
215
+ @include m.is(leaving) {
216
+ pointer-events: none;
217
+
218
+ .u-ai-orb {
219
+ opacity: 0;
220
+ transform: scale(0);
221
+ transition:
222
+ opacity 0.28s ease,
223
+ transform 0.38s ease;
224
+ }
225
+
226
+ .u-#{$root-name}__welcome-item-wrap {
227
+ opacity: 0;
228
+ transition: opacity 0.2s ease;
229
+ }
230
+ }
197
231
  }
198
232
 
199
233
  @include m.e(welcome-inner) {
@@ -207,14 +241,14 @@ $root-name: ai-chat;
207
241
  @include m.e(welcome-item-wrap) {
208
242
  flex: 1;
209
243
  min-width: 0;
244
+ height: 48px;
210
245
  display: flex;
211
246
  align-items: center;
212
- // 预留两行高度:长文案换行时行高不抖动
213
- min-height: 54px;
214
247
  }
215
248
 
216
249
  @include m.e(welcome-item) {
217
250
  max-width: 100%;
251
+ @include m.ellipsis;
218
252
  padding: fn.use-var(gap, small) fn.use-var(gap, default);
219
253
  border: none;
220
254
  border-radius: fn.use-var(radius, large);
@@ -261,12 +295,44 @@ $root-name: ai-chat;
261
295
  @include m.flex(flex, flex-start, center);
262
296
  gap: fn.use-var(gap, small);
263
297
  padding: 0 fn.use-var(gap, default) fn.use-var(gap, small);
298
+ width: fit-content;
299
+
300
+ // 工作开始:立即从 0 放大(无延时),与输入框上方缩小形成对向跳
301
+ .u-ai-orb {
302
+ transform-origin: center;
303
+ animation: u-ai-chat-orb-jump-in 0.38s ease both;
304
+ }
305
+
306
+ @include m.is(leaving) {
307
+ pointer-events: none;
308
+
309
+ .u-ai-orb {
310
+ animation: none;
311
+ opacity: 0;
312
+ transform: scale(0);
313
+ transition:
314
+ opacity 0.28s ease,
315
+ transform 0.38s ease;
316
+ }
317
+ }
264
318
  }
265
319
 
266
320
  @include m.e(working-text) {
267
321
  font-size: fn.use-var(font-size-main, small);
268
322
  }
269
323
 
324
+ @media (prefers-reduced-motion: reduce) {
325
+ .u-#{$root-name}__welcome.is-enter .u-ai-orb,
326
+ .u-#{$root-name}__working .u-ai-orb {
327
+ animation: none;
328
+ }
329
+
330
+ .u-#{$root-name}__welcome.is-leaving .u-ai-orb,
331
+ .u-#{$root-name}__working.is-leaving .u-ai-orb {
332
+ transition: none;
333
+ }
334
+ }
335
+
270
336
  // 「最新消息」悬浮按钮:上翻浏览时出现,点击回到底部
271
337
  @include m.e(to-latest) {
272
338
  position: absolute;
@@ -382,7 +448,7 @@ $root-name: ai-chat;
382
448
 
383
449
  @include m.e(message-status) {
384
450
  margin-top: fn.use-var(gap, small);
385
- color: fn.use-var(text-color, assist);
451
+ color: fn.use-var(text-color, second);
386
452
  font-size: fn.use-var(font-size-main, small);
387
453
  }
388
454
 
@@ -395,7 +461,7 @@ $root-name: ai-chat;
395
461
  // 思考过程
396
462
  @include m.e(reasoning) {
397
463
  margin-bottom: 4px;
398
- color: fn.use-var(text-color, assist);
464
+ color: fn.use-var(text-color, second);
399
465
 
400
466
  @include m.is(expanded) {
401
467
  @include m.bem($root-name, 'reasoning-chevron') {
@@ -435,10 +501,54 @@ $root-name: ai-chat;
435
501
  line-height: 1.55;
436
502
  }
437
503
 
438
- // 工具调用卡片(复用 UCollapseItem:头部经 #header 插槽自定义,body 为折叠内容区)
504
+ // 轮次过程折叠块(最终答案输出后,之前的思考/工具调用过程收进「已完成」)
505
+ @include m.e(process) {
506
+ padding: 4px fn.use-var(gap, default);
507
+ color: fn.use-var(text-color, second);
508
+
509
+ @include m.is(expanded) {
510
+ @include m.bem($root-name, 'process-chevron') {
511
+ transform: rotate(90deg);
512
+ }
513
+ }
514
+ }
515
+
516
+ @include m.e(process-header) {
517
+ @include m.flex(flex, flex-start, center);
518
+ gap: 4px;
519
+ cursor: pointer;
520
+ user-select: none;
521
+ font-size: fn.use-var(font-size-main, small);
522
+ }
523
+
524
+ @include m.e(process-icon) {
525
+ color: fn.use-var(color, success);
526
+ }
527
+
528
+ @include m.e(process-title) {
529
+ line-height: 1.5;
530
+ }
531
+
532
+ @include m.e(process-chevron) {
533
+ font-size: 12px;
534
+ transition: transform 0.2s;
535
+ }
536
+
537
+ @include m.e(process-content) {
538
+ margin-top: 4px;
539
+ padding-left: fn.use-var(gap, small);
540
+ border-left: fn.use-var(border-muted);
541
+
542
+ // 过程消息在边线内对齐:抵消列表项横向 padding
543
+ .u-#{$root-name}__message {
544
+ padding-left: 0;
545
+ padding-right: 0;
546
+ }
547
+ }
548
+
549
+ // 工具调用卡片(复用 UCollapseItem:背景/边框走组件原生配色,#header 只自定义标题区)
439
550
  @include m.e(tool-call) {
440
551
  margin: 4px 0;
441
- background-color: fn.use-var(bg-color, top);
442
552
  font-size: fn.use-var(font-size-main, small);
443
553
 
444
554
  // 相邻工具卡片保持紧凑间距(覆盖 UCollapseItem 的兄弟间距)
@@ -455,18 +565,11 @@ $root-name: ai-chat;
455
565
  }
456
566
 
457
567
  .u-collapse__content {
458
- border-top: fn.use-var(border-muted);
459
568
  padding: fn.use-var(gap, small);
460
569
  line-height: 1.5;
461
570
  }
462
571
  }
463
572
 
464
- @include m.e(tool-call-chevron) {
465
- flex-shrink: 0;
466
- font-size: 12px;
467
- color: fn.use-var(text-color, assist);
468
- }
469
-
470
573
  @include m.e(tool-call-status) {
471
574
  flex-shrink: 0;
472
575
  font-size: 13px;
@@ -760,7 +863,7 @@ $root-name: ai-chat;
760
863
  width: 100%;
761
864
  max-width: 800px;
762
865
  box-sizing: border-box;
763
- margin: fn.use-var(gap, small) 0 fn.use-var(gap, default);
866
+ margin: 0 0 fn.use-var(gap, default);
764
867
  border-radius: fn.use-var(radius, large);
765
868
  background-color: fn.use-var(bg-color, top);
766
869
  padding: fn.use-var(gap, small);
@@ -24,8 +24,6 @@
24
24
  <UButton size="small" type="primary" @click="emit('respond', true)">允许</UButton>
25
25
  <UButton size="small" text @click="emit('respond', false)">拒绝</UButton>
26
26
  </span>
27
-
28
- <UIcon :class="cls.e('tool-call-chevron')"><ArrowDown /></UIcon>
29
27
  </template>
30
28
 
31
29
  <!-- 面板工具:render 展示在右侧侧边面板,卡片 body 仅提供查看入口 -->
@@ -56,14 +54,7 @@
56
54
 
57
55
  <script lang="ts" setup>
58
56
  import { UButton, UCollapseItem, UIcon } from '@veltra/desktop'
59
- import {
60
- ArrowDown,
61
- CircleCheckFilled,
62
- CircleClose,
63
- Close,
64
- Loading,
65
- WarningFilled
66
- } from '@veltra/icons/normal'
57
+ import { CircleCheckFilled, CircleClose, Close, Loading, WarningFilled } from '@veltra/icons/normal'
67
58
  import { bem } from '@veltra/utils'
68
59
  import { computed, inject, ref, watch, type Component } from 'vue'
69
60
 
@@ -0,0 +1,47 @@
1
+ <template>
2
+ <div :class="[cls.e('process'), bem.is('expanded', expanded)]">
3
+ <div :class="cls.e('process-header')" @click="expanded = !expanded">
4
+ <UIcon :class="cls.e('process-icon')"><CircleCheck /></UIcon>
5
+ <span :class="cls.e('process-title')">已完成</span>
6
+ <UIcon :class="cls.e('process-chevron')"><ArrowRight /></UIcon>
7
+ </div>
8
+ <!-- 折叠时完全卸载过程 DOM:历史轮次的思考/工具卡片可能很多,常驻挂载浪费渲染成本 -->
9
+ <div v-if="expanded" :class="cls.e('process-content')">
10
+ <MessageItem
11
+ v-for="msg in messages"
12
+ :key="msg.id"
13
+ :message="msg"
14
+ :is-last="false"
15
+ :renderer-props="rendererProps"
16
+ @respond="(id, approved) => emit('respond', id, approved)"
17
+ />
18
+ </div>
19
+ </div>
20
+ </template>
21
+
22
+ <script lang="ts" setup>
23
+ import { UIcon } from '@veltra/desktop'
24
+ import { ArrowRight, CircleCheck } from '@veltra/icons/normal'
25
+ import { bem } from '@veltra/utils'
26
+ import { inject, ref } from 'vue'
27
+
28
+ import type { ChatMessage } from '../../chat/types'
29
+ import { AiChatDIKey } from './di'
30
+ import MessageItem from './message-item.vue'
31
+
32
+ defineOptions({ name: 'UAiChatTurnProcess' })
33
+
34
+ defineProps<{
35
+ /** 本轮最终答案之前的过程消息(思考 + 中间文本 + 工具卡片) */
36
+ messages: ChatMessage[]
37
+ /** 透传给 MarkdownRender 的属性 */
38
+ rendererProps?: Record<string, unknown>
39
+ }>()
40
+
41
+ const emit = defineEmits<{ (e: 'respond', toolCallId: string, approved: boolean): void }>()
42
+
43
+ const di = inject(AiChatDIKey)
44
+ const cls = di?.cls ?? bem('ai-chat')
45
+
46
+ const expanded = ref(false)
47
+ </script>