@veltra/ai 2.1.5 → 2.1.7

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 (74) hide show
  1. package/README.md +42 -0
  2. package/dist/chat/fold.d.ts +41 -0
  3. package/dist/chat/fold.js +208 -0
  4. package/dist/chat/fold.js.map +1 -0
  5. package/dist/chat/index.d.ts +4 -2
  6. package/dist/chat/session.d.ts +123 -0
  7. package/dist/chat/session.js +79 -0
  8. package/dist/chat/session.js.map +1 -0
  9. package/dist/chat/types.d.ts +38 -1
  10. package/dist/chat/use-chat-helpers.js +39 -0
  11. package/dist/chat/use-chat-helpers.js.map +1 -0
  12. package/dist/chat/use-chat-session.js +68 -0
  13. package/dist/chat/use-chat-session.js.map +1 -0
  14. package/dist/chat/use-chat.d.ts +50 -2
  15. package/dist/chat/use-chat.js +73 -39
  16. package/dist/chat/use-chat.js.map +1 -1
  17. package/dist/components/ai-chat/ai-chat.js +49 -14
  18. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  19. package/dist/components/ai-chat/approval-banner.js +54 -0
  20. package/dist/components/ai-chat/approval-banner.js.map +1 -0
  21. package/dist/components/ai-chat/ask-question.js +32 -51
  22. package/dist/components/ai-chat/ask-question.js.map +1 -1
  23. package/dist/components/ai-chat/chat-input.js +9 -3
  24. package/dist/components/ai-chat/chat-input.js.map +1 -1
  25. package/dist/components/ai-chat/di.js.map +1 -1
  26. package/dist/components/ai-chat/job-bar.js +48 -0
  27. package/dist/components/ai-chat/job-bar.js.map +1 -0
  28. package/dist/components/ai-chat/message-list.js +5 -2
  29. package/dist/components/ai-chat/message-list.js.map +1 -1
  30. package/dist/components/ai-chat/queue-list.js +9 -3
  31. package/dist/components/ai-chat/queue-list.js.map +1 -1
  32. package/dist/components/ai-chat/side-panel.js +12 -30
  33. package/dist/components/ai-chat/side-panel.js.map +1 -1
  34. package/dist/components/ai-chat/style2.css +150 -8
  35. package/dist/components/ai-chat/tool-call.js +51 -40
  36. package/dist/components/ai-chat/tool-call.js.map +1 -1
  37. package/dist/components/ai-chat/tool-icons.js +31 -0
  38. package/dist/components/ai-chat/tool-icons.js.map +1 -0
  39. package/dist/components/ai-chat/tool-json.js +40 -0
  40. package/dist/components/ai-chat/tool-json.js.map +1 -0
  41. package/dist/components/ai-orb/style2.css +1 -1
  42. package/dist/index.d.ts +6 -4
  43. package/dist/index.js +3 -1
  44. package/dist/tools/ask-question/ask-question.js +48 -2
  45. package/dist/tools/ask-question/ask-question.js.map +1 -1
  46. package/dist/types/ai-chat.d.ts +17 -6
  47. package/package.json +7 -7
  48. package/src/chat/__test__/fold.test.ts +136 -0
  49. package/src/chat/__test__/session.test.ts +191 -0
  50. package/src/chat/__test__/use-chat.test.ts +268 -1
  51. package/src/chat/fold.ts +260 -0
  52. package/src/chat/index.ts +2 -0
  53. package/src/chat/session.ts +168 -0
  54. package/src/chat/types.ts +40 -0
  55. package/src/chat/use-chat-helpers.ts +39 -0
  56. package/src/chat/use-chat-session.ts +87 -0
  57. package/src/chat/use-chat.ts +90 -44
  58. package/src/components/ai-chat/__test__/ai-chat.test.ts +405 -7
  59. package/src/components/ai-chat/__test__/tool-icons.test.ts +33 -0
  60. package/src/components/ai-chat/ai-chat.vue +39 -4
  61. package/src/components/ai-chat/approval-banner.vue +37 -0
  62. package/src/components/ai-chat/ask-question.vue +38 -54
  63. package/src/components/ai-chat/chat-input.vue +10 -2
  64. package/src/components/ai-chat/di.ts +6 -4
  65. package/src/components/ai-chat/job-bar.vue +41 -0
  66. package/src/components/ai-chat/message-list.vue +4 -1
  67. package/src/components/ai-chat/queue-list.vue +3 -1
  68. package/src/components/ai-chat/side-panel.vue +15 -13
  69. package/src/components/ai-chat/style.scss +162 -6
  70. package/src/components/ai-chat/tool-call.vue +46 -34
  71. package/src/components/ai-chat/tool-icons.ts +33 -0
  72. package/src/components/ai-chat/tool-json.vue +52 -0
  73. package/src/tools/ask-question/ask-question.ts +53 -4
  74. package/src/types/ai-chat.ts +17 -5
@@ -0,0 +1,33 @@
1
+ import { EditPen, Folder, Internet, QuestionFilled, Terminal, Tools } from '@veltra/icons/normal'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { defineComponent, h } from 'vue'
4
+
5
+ import { FALLBACK_TOOL_ICON, resolveToolIcon } from '../tool-icons'
6
+
7
+ describe('resolveToolIcon', () => {
8
+ it('按名称子串匹配内置规则', () => {
9
+ expect(resolveToolIcon('run_bash')).toBe(Terminal)
10
+ expect(resolveToolIcon('Terminal')).toBe(Terminal)
11
+ expect(resolveToolIcon('fs_read')).toBe(Folder)
12
+ expect(resolveToolIcon('read_file')).toBe(Folder)
13
+ expect(resolveToolIcon('web_search')).toBe(Internet)
14
+ expect(resolveToolIcon('globe-fetch')).toBe(Internet)
15
+ expect(resolveToolIcon('str_replace_editor')).toBe(EditPen)
16
+ expect(resolveToolIcon('pencil')).toBe(EditPen)
17
+ expect(resolveToolIcon('askQuestion')).toBe(QuestionFilled)
18
+ expect(resolveToolIcon('clarifying_question')).toBe(QuestionFilled)
19
+ })
20
+
21
+ it('未知名与空名走兜底,不得 throw', () => {
22
+ expect(resolveToolIcon('totally-unknown-tool-xyz')).toBe(Tools)
23
+ expect(resolveToolIcon('totally-unknown-tool-xyz')).toBe(FALLBACK_TOOL_ICON)
24
+ expect(resolveToolIcon('')).toBe(FALLBACK_TOOL_ICON)
25
+ expect(() => resolveToolIcon('totally-unknown-tool-xyz')).not.toThrow()
26
+ })
27
+
28
+ it('宿主覆盖表精确名优先于内置规则', () => {
29
+ const Override = defineComponent({ setup: () => () => h('span') })
30
+ expect(resolveToolIcon('run_bash', { run_bash: Override })).toBe(Override)
31
+ expect(resolveToolIcon('run_bash', { other: Override })).toBe(Terminal)
32
+ })
33
+ })
@@ -14,6 +14,7 @@
14
14
  :messages="messages"
15
15
  :welcome="welcome"
16
16
  :running="running"
17
+ :readonly="readonly"
17
18
  :renderer-props="rendererProps"
18
19
  @respond="respondToolCall"
19
20
  @regenerate="regenerate"
@@ -24,14 +25,24 @@
24
25
  </template>
25
26
  </MessageList>
26
27
 
28
+ <div v-if="pendingQuestion" :class="cls.e('session-question')">
29
+ <AskQuestion :questions="pendingQuestion.questions" @submit="handleQuestionSubmit" />
30
+ </div>
31
+
32
+ <ApprovalBanner :approvals="pendingApprovals" @respond="respondSession" />
33
+
27
34
  <QueueList
28
35
  :queue="queue"
36
+ :readonly="readonly"
29
37
  @start-now="startQueued"
30
38
  @edit="handleQueueEdit"
31
39
  @remove="removeQueued"
32
40
  />
33
41
 
42
+ <JobBar :jobs="jobs" />
43
+
34
44
  <ChatInput
45
+ v-if="!readonly"
35
46
  ref="chatInputRef"
36
47
  v-model:model="model"
37
48
  v-model:reasoning-level="reasoningLevel"
@@ -65,12 +76,17 @@ import { ULayout } from '@veltra/desktop'
65
76
  import { bem } from '@veltra/utils'
66
77
  import { computed, provide, ref, useSlots, useTemplateRef, watch } from 'vue'
67
78
 
68
- import type { ChatAttachment, ChatToolCall } from '../../chat/types'
79
+ import { isServerTransport } from '../../chat/session'
80
+ import type { ChatAttachment, ChatTool, ChatToolCall, ChatToolMeta } from '../../chat/types'
69
81
  import { useChat } from '../../chat/use-chat'
70
82
  import { createBuiltinTools } from '../../tools'
83
+ import type { AskQuestionAnswer, AskQuestionResult } from '../../tools/ask-question/ask-question'
71
84
  import type { _AiChatExposed, AiChatEmits, AiChatProps } from '../../types'
85
+ import ApprovalBanner from './approval-banner.vue'
86
+ import AskQuestion from './ask-question.vue'
72
87
  import ChatInput from './chat-input.vue'
73
88
  import { AiChatDIKey } from './di'
89
+ import JobBar from './job-bar.vue'
74
90
  import MessageList from './message-list.vue'
75
91
  import QueueList from './queue-list.vue'
76
92
  import SidePanel from './side-panel.vue'
@@ -92,27 +108,39 @@ const cls = bem('ai-chat')
92
108
 
93
109
  const slots = useSlots()
94
110
 
95
- /** 内置 + 用户工具(同名内置优先),供工具卡片解析 icon/label/render/renderTo/autoCollapse */
96
- const toolMap = computed(() => {
111
+ /**
112
+ * 按 name 查表:session 下 tools 仅为渲染元信息(不注入内置工具,忽略 execute 等)。
113
+ * 函数 transport 仍注入 createBuiltinTools(同名内置优先)。
114
+ */
115
+ const toolMap = computed((): Record<string, ChatTool | ChatToolMeta | undefined> => {
116
+ if (isServerTransport(props.transport)) {
117
+ return Object.fromEntries((props.tools ?? []).map((t) => [t.name, t]))
118
+ }
97
119
  const builtins = createBuiltinTools()
98
120
  const names = new Set(builtins.map((t) => t.name))
99
121
  const tools = [...builtins, ...(props.tools ?? []).filter((t) => !names.has(t.name))]
100
122
  return Object.fromEntries(tools.map((t) => [t.name, t]))
101
123
  })
102
124
 
125
+ const toolIcons = computed(() => props.toolIcons)
126
+
103
127
  const {
104
128
  messages,
105
129
  model,
106
130
  reasoningLevel,
107
131
  running,
108
132
  queue,
133
+ jobs,
109
134
  tokenUsage,
110
135
  lastTurnUsage,
136
+ pendingApprovals,
137
+ pendingQuestion,
111
138
  send,
112
139
  abort,
113
140
  regenerate,
114
141
  clear,
115
142
  respondToolCall,
143
+ respondSession,
116
144
  enqueue,
117
145
  startQueued,
118
146
  removeQueued
@@ -175,7 +203,7 @@ const closePanel = () => {
175
203
  activePanelCallId.value = null
176
204
  }
177
205
 
178
- provide(AiChatDIKey, { cls, slots, tools: toolMap, openPanel })
206
+ provide(AiChatDIKey, { cls, slots, tools: toolMap, toolIcons, openPanel })
179
207
 
180
208
  /**
181
209
  * 面板宽度:经 ULayout 列轨施加(cols = 1fr + 面板宽度),
@@ -266,6 +294,7 @@ const restoreEditing = () => {
266
294
  }
267
295
 
268
296
  const handleSend = (content: string, attachments: ChatAttachment[]) => {
297
+ if (props.readonly) return
269
298
  // 编辑态提交:插回原锚点位置(会话进行中则留在队列,空闲时由 enqueue 自动消耗队首)
270
299
  if (editingSuccessor.value !== undefined) {
271
300
  enqueue(content, attachments, editingSuccessor.value ?? undefined)
@@ -275,6 +304,12 @@ const handleSend = (content: string, attachments: ChatAttachment[]) => {
275
304
  send(content, attachments)
276
305
  }
277
306
 
307
+ const handleQuestionSubmit = (value: AskQuestionAnswer[] | AskQuestionResult) => {
308
+ const pending = pendingQuestion.value
309
+ if (!pending) return
310
+ respondSession(pending.rpcId, true, Array.isArray(value) ? value : value.answers)
311
+ }
312
+
278
313
  defineExpose<_AiChatExposed>({
279
314
  send,
280
315
  abort,
@@ -0,0 +1,37 @@
1
+ <template>
2
+ <div v-if="items.length" :class="cls.e('approval-banner')">
3
+ <div v-for="item in items" :key="item.approvalId" :class="cls.e('approval-banner-card')">
4
+ <div :class="cls.e('approval-banner-title')">需要确认</div>
5
+ <div v-if="item.reason || item.toolName" :class="cls.e('approval-banner-reason')">
6
+ {{ item.reason ?? item.toolName }}
7
+ </div>
8
+ <div :class="cls.e('approval-banner-actions')">
9
+ <UButton size="small" type="primary" @click="emit('respond', item.rpcId, true)">
10
+ 允许
11
+ </UButton>
12
+ <UButton size="small" text @click="emit('respond', item.rpcId, false)">拒绝</UButton>
13
+ </div>
14
+ </div>
15
+ </div>
16
+ </template>
17
+
18
+ <script lang="ts" setup>
19
+ import { UButton } from '@veltra/desktop'
20
+ import { bem } from '@veltra/utils'
21
+ import { computed, inject } from 'vue'
22
+
23
+ import type { ChatPendingApproval } from '../../chat/fold'
24
+ import { AiChatDIKey } from './di'
25
+
26
+ defineOptions({ name: 'UAiChatApprovalBanner' })
27
+
28
+ const props = defineProps<{ approvals: ChatPendingApproval[] }>()
29
+
30
+ const emit = defineEmits<{ (e: 'respond', rpcId: string, ok: boolean): void }>()
31
+
32
+ const di = inject(AiChatDIKey)
33
+ const cls = di?.cls ?? bem('ai-chat')
34
+
35
+ /** 仅无 callId 的审批走横幅;有 callId 的走工具卡确认 */
36
+ const items = computed(() => props.approvals.filter((item) => !item.callId))
37
+ </script>
@@ -81,7 +81,7 @@
81
81
  </template>
82
82
 
83
83
  <!-- 提交后:问答摘要 -->
84
- <template v-else-if="toolCall.status === 'success'">
84
+ <template v-else-if="answers != null">
85
85
  <div v-for="(item, i) in doneAnswers" :key="i" :class="cls.e('ask-question-result')">
86
86
  <div :class="cls.e('ask-question-result-q')">
87
87
  <span
@@ -101,7 +101,7 @@
101
101
  </template>
102
102
 
103
103
  <!-- 异常 / 取消 -->
104
- <div v-else :class="cls.e('ask-question-status')">{{ statusText }}</div>
104
+ <div v-else :class="cls.e('ask-question-status')">{{ error }}</div>
105
105
  </div>
106
106
  </template>
107
107
 
@@ -110,39 +110,34 @@ import { UButton, UInput } from '@veltra/desktop'
110
110
  import { bem } from '@veltra/utils'
111
111
  import { computed, inject, ref, watch } from 'vue'
112
112
 
113
- import type { ChatToolCall } from '../../chat/types'
114
113
  import type {
115
- AskQuestionArgs,
114
+ AskQuestionAnswer,
116
115
  AskQuestionItem,
117
116
  AskQuestionResult
118
117
  } from '../../tools/ask-question/ask-question'
119
- import { resolveAskQuestion } from '../../tools/ask-question/deferred'
120
118
  import { AiChatDIKey } from './di'
121
119
 
122
120
  defineOptions({ name: 'UAiChatAskQuestion' })
123
121
 
124
- const props = defineProps<{ toolCall: ChatToolCall }>()
122
+ const props = defineProps<{
123
+ /** 问题列表;组件不再从 toolCall 解析参数 */
124
+ questions: AskQuestionItem[]
125
+ /** 已提交摘要;有值则展示结果而非表单 */
126
+ answers?: AskQuestionAnswer[]
127
+ /** 失败 / 取消文案 */
128
+ error?: string
129
+ }>()
130
+
131
+ const emit = defineEmits<{
132
+ /** 提交问答;值为答案数组(与 AskQuestionResult 兼容) */
133
+ (e: 'submit', value: AskQuestionAnswer[] | AskQuestionResult): void
134
+ }>()
125
135
 
126
136
  const di = inject(AiChatDIKey)
127
137
  const cls = di?.cls ?? bem('ai-chat')
128
138
 
129
- /** 模型输出的提问参数 */
130
- const questions = computed<AskQuestionItem[]>(() => {
131
- try {
132
- const args = JSON.parse(props.toolCall.arguments || '{}') as Partial<AskQuestionArgs>
133
- return Array.isArray(args.questions) ? args.questions : []
134
- } catch {
135
- return []
136
- }
137
- })
138
-
139
- /** 作答中:execute 挂起等待用户提交 */
140
- const answering = computed(() => {
141
- return (
142
- props.toolCall.result == null &&
143
- (props.toolCall.status === 'running' || props.toolCall.status === 'pending')
144
- )
145
- })
139
+ /** 作答中:尚未提交、也没有失败文案 */
140
+ const answering = computed(() => props.answers == null && !props.error)
146
141
 
147
142
  /** 当前题索引 */
148
143
  const current = ref(0)
@@ -152,16 +147,16 @@ const selections = ref<(string | null)[]>([])
152
147
  const customs = ref<string[]>([])
153
148
 
154
149
  watch(
155
- questions,
150
+ () => props.questions,
156
151
  (list) => {
157
- if (props.toolCall.result != null) return
152
+ if (props.answers != null) return
158
153
  selections.value = list.map(() => null)
159
154
  customs.value = list.map(() => '')
160
155
  },
161
156
  { immediate: true }
162
157
  )
163
158
 
164
- const currentQuestion = computed(() => questions.value[current.value])
159
+ const currentQuestion = computed(() => props.questions[current.value])
165
160
 
166
161
  /** 某题是否已作答:自定义输入非空优先,否则看选中项 */
167
162
  const isAnswered = (index: number) => {
@@ -169,11 +164,11 @@ const isAnswered = (index: number) => {
169
164
  }
170
165
 
171
166
  const allAnswered = computed(() => {
172
- return questions.value.length > 0 && questions.value.every((_, i) => isAnswered(i))
167
+ return props.questions.length > 0 && props.questions.every((_, i) => isAnswered(i))
173
168
  })
174
169
 
175
170
  const goTo = (index: number) => {
176
- if (index < 0 || index >= questions.value.length) return
171
+ if (index < 0 || index >= props.questions.length) return
177
172
  current.value = index
178
173
  }
179
174
 
@@ -192,41 +187,30 @@ const onCustomInput = (value: string) => {
192
187
  }
193
188
 
194
189
  const handleSubmit = () => {
195
- const firstUnanswered = questions.value.findIndex((_, i) => !isAnswered(i))
190
+ const firstUnanswered = props.questions.findIndex((_, i) => !isAnswered(i))
196
191
  if (firstUnanswered >= 0) {
197
192
  current.value = firstUnanswered
198
193
  return
199
194
  }
200
- const answers = questions.value.map((item, i) => ({
201
- question: item.question,
202
- answer: customs.value[i]?.trim() || selections.value[i] || ''
203
- }))
204
- resolveAskQuestion(props.toolCall.id, { answers })
195
+ emit(
196
+ 'submit',
197
+ props.questions.map((item, i) => ({
198
+ question: item.question,
199
+ answer: customs.value[i]?.trim() || selections.value[i] || ''
200
+ }))
201
+ )
205
202
  }
206
203
 
207
- /** 提交后的问答摘要(含历史恢复:从序列化结果解析) */
204
+ /** 提交后的问答摘要:命中预设选项的回答渲染为 chip */
208
205
  const doneAnswers = computed(() => {
209
- if (props.toolCall.status !== 'success' || !props.toolCall.result) return []
210
- let result: AskQuestionResult
211
- try {
212
- result = JSON.parse(props.toolCall.result)
213
- } catch {
214
- return []
215
- }
216
- if (!Array.isArray(result?.answers)) return []
217
- return result.answers.map((item) => ({
218
- ...item,
219
- /** 命中预设选项的回答渲染为 chip */
220
- isOption: questions.value.some(
206
+ const list = props.answers
207
+ if (!list?.length) return []
208
+ return list.map((item) => ({
209
+ question: item.question,
210
+ answer: item.answer,
211
+ isOption: props.questions.some(
221
212
  (q) => q.question === item.question && q.options?.includes(item.answer)
222
213
  )
223
214
  }))
224
215
  })
225
-
226
- const statusText = computed(() => {
227
- if (props.toolCall.status !== 'error') return ''
228
- const err = props.toolCall.error ?? ''
229
- if (err.includes('Abort') || err.includes('取消')) return '已取消'
230
- return err || '提问失败'
231
- })
232
216
  </script>
@@ -92,7 +92,7 @@
92
92
  import { UButton, UFilePicker, UIcon, UPopConfirm } from '@veltra/desktop'
93
93
  import { Attach, Clear, Close, Up } from '@veltra/icons/normal'
94
94
  import { bem } from '@veltra/utils'
95
- import { computed, inject, nextTick, ref, shallowRef } from 'vue'
95
+ import { computed, inject, nextTick, ref, shallowRef, watch } from 'vue'
96
96
 
97
97
  import type { ChatAttachment, ChatTokenUsage } from '../../chat/types'
98
98
  import type { ChatModelOption } from '../../providers'
@@ -181,14 +181,22 @@ const placeholderText = computed(() => {
181
181
  return props.placeholder ?? '输入消息,Enter 发送,Shift + Enter 换行'
182
182
  })
183
183
 
184
+ const MAX_INPUT_HEIGHT = 160
185
+
184
186
  /** 多行自适应高度,上限 160px */
185
187
  const autoResize = () => {
186
188
  const el = textareaRef.value
187
189
  if (!el) return
188
190
  el.style.height = 'auto'
189
- el.style.height = `${Math.min(el.scrollHeight, 160)}px`
191
+ const nextHeight = Math.min(el.scrollHeight, MAX_INPUT_HEIGHT)
192
+ el.style.height = `${nextHeight}px`
193
+ el.style.overflowY = el.scrollHeight > MAX_INPUT_HEIGHT ? 'auto' : 'hidden'
190
194
  }
191
195
 
196
+ watch(placeholderText, () => {
197
+ void nextTick(autoResize)
198
+ })
199
+
192
200
  const handleSend = () => {
193
201
  if (!hasContent.value) return
194
202
  emit('send', text.value.trim(), attachments.value)
@@ -1,15 +1,17 @@
1
1
  import type { BEM } from '@veltra/utils'
2
- import type { ComputedRef, InjectionKey, Slots } from 'vue'
2
+ import type { Component, ComputedRef, InjectionKey, Slots } from 'vue'
3
3
 
4
- import type { ChatTool } from '../../chat/types'
4
+ import type { ChatTool, ChatToolMeta } from '../../chat/types'
5
5
 
6
6
  export interface AiChatContext {
7
7
  /** ai-chat 组件的 BEM 类生成器 */
8
8
  cls: BEM<'ai-chat'>
9
9
  /** ai-chat 使用方提供的插槽(用于 tool-<name> 动态插槽) */
10
10
  slots: Slots
11
- /** 按 name 索引的工具定义(解析 icon/label/render/autoCollapse) */
12
- tools: ComputedRef<Record<string, ChatTool | undefined>>
11
+ /** 按 name 索引的工具定义或渲染元信息(解析 icon/label/render/autoCollapse) */
12
+ tools: ComputedRef<Record<string, ChatTool | ChatToolMeta | undefined>>
13
+ /** 宿主覆盖的 toolName → icon;缺省走包内名称规则 */
14
+ toolIcons: ComputedRef<Record<string, Component> | undefined>
13
15
  /** 打开右侧面板并聚焦到指定工具调用(renderTo: 'panel' 工具的「查看面板」入口) */
14
16
  openPanel: (toolCallId: string) => void
15
17
  }
@@ -0,0 +1,41 @@
1
+ <template>
2
+ <div v-if="jobs.length" :class="cls.e('job-bar')">
3
+ <button type="button" :class="cls.e('job-bar-head')" @click="expanded = !expanded">
4
+ <span>作业 · {{ jobs.length }}</span>
5
+ <UIcon :class="[cls.e('job-bar-chevron'), bem.is('expanded', expanded)]">
6
+ <ArrowRight />
7
+ </UIcon>
8
+ </button>
9
+ <div v-if="expanded" :class="cls.e('job-bar-list')">
10
+ <div v-for="job in jobs" :key="job.id" :class="cls.e('job-bar-item')">
11
+ <UIcon :class="cls.e('job-bar-icon')">
12
+ <component :is="resolveToolIcon(job.kind)" />
13
+ </UIcon>
14
+ <span :class="[cls.e('job-bar-label'), job.status === 'running' && 'u-shine']">
15
+ {{ job.label }}
16
+ </span>
17
+ <span :class="[cls.e('job-bar-dot'), bem.is(job.status)]" />
18
+ </div>
19
+ </div>
20
+ </div>
21
+ </template>
22
+
23
+ <script lang="ts" setup>
24
+ import { UIcon } from '@veltra/desktop'
25
+ import { ArrowRight } from '@veltra/icons/normal'
26
+ import { bem } from '@veltra/utils'
27
+ import { inject, ref } from 'vue'
28
+
29
+ import type { ChatJob } from '../../chat/types'
30
+ import { AiChatDIKey } from './di'
31
+ import { resolveToolIcon } from './tool-icons'
32
+
33
+ defineOptions({ name: 'UAiChatJobBar' })
34
+
35
+ defineProps<{ jobs: ChatJob[] }>()
36
+
37
+ const expanded = ref(true)
38
+
39
+ const di = inject(AiChatDIKey)
40
+ const cls = di?.cls ?? bem('ai-chat')
41
+ </script>
@@ -70,7 +70,8 @@
70
70
  :key="welcomeIndex"
71
71
  type="button"
72
72
  :class="cls.e('welcome-item')"
73
- @click="emit('welcome-click', currentWelcomeItem)"
73
+ :disabled="readonly"
74
+ @click="!readonly && emit('welcome-click', currentWelcomeItem)"
74
75
  >
75
76
  {{ currentWelcomeItem }}
76
77
  </button>
@@ -128,6 +129,8 @@ const props = defineProps<{
128
129
  welcome?: string | string[]
129
130
  /** 是否生成中(控制底部工作状态指示) */
130
131
  running?: boolean
132
+ /** 只读时欢迎语不可点击发送 */
133
+ readonly?: boolean
131
134
  /** 透传给 MarkdownRender 的属性 */
132
135
  rendererProps?: Record<string, unknown>
133
136
  }>()
@@ -3,7 +3,7 @@
3
3
  <div :class="cls.e('queue-head')">待发送队列 · {{ queue.length }}</div>
4
4
  <div v-for="item in queue" :key="item.id" :class="cls.e('queue-item')">
5
5
  <span :class="cls.e('queue-text')" :title="item.content">{{ item.content }}</span>
6
- <span :class="cls.e('queue-actions')">
6
+ <span v-if="!readonly" :class="cls.e('queue-actions')">
7
7
  <button
8
8
  type="button"
9
9
  :class="[cls.e('queue-action'), cls.em('queue-action', 'primary')]"
@@ -47,6 +47,8 @@ defineOptions({ name: 'UAiChatQueueList' })
47
47
  defineProps<{
48
48
  /** 待发送队列(按执行顺序排列) */
49
49
  queue: ChatQueuedMessage[]
50
+ /** 只读时不展示插队 / 编辑 / 移除 */
51
+ readonly?: boolean
50
52
  }>()
51
53
 
52
54
  const emit = defineEmits<{
@@ -22,12 +22,13 @@
22
22
 
23
23
  <script lang="ts" setup>
24
24
  import { UIcon, UScroll } from '@veltra/desktop'
25
- import { CircleCheckFilled, CircleClose, Close, Loading, WarningFilled } from '@veltra/icons/normal'
25
+ import { Close } from '@veltra/icons/normal'
26
26
  import { bem } from '@veltra/utils'
27
- import { computed, inject, type Component } from 'vue'
27
+ import { computed, inject } from 'vue'
28
28
 
29
29
  import type { ChatToolCall } from '../../chat/types'
30
30
  import { AiChatDIKey } from './di'
31
+ import { resolveToolIcon } from './tool-icons'
31
32
 
32
33
  defineOptions({ name: 'UAiChatSidePanel' })
33
34
 
@@ -41,20 +42,21 @@ const cls = di?.cls ?? bem('ai-chat')
41
42
  /** 当前调用对应的工具定义(解析 icon/label/render) */
42
43
  const tool = computed(() => di?.tools.value[props.toolCall.name])
43
44
 
44
- const STATUS_META: Record<string, { icon: Component; class: string }> = {
45
- pending: { icon: Loading, class: 'is-active' },
46
- running: { icon: Loading, class: 'is-active' },
47
- 'awaiting-confirm': { icon: WarningFilled, class: 'is-warning' },
48
- success: { icon: CircleCheckFilled, class: 'is-success' },
49
- error: { icon: CircleClose, class: 'is-danger' },
50
- rejected: { icon: Close, class: 'is-danger' }
45
+ const STATUS_CLASS: Record<string, string> = {
46
+ pending: 'is-active',
47
+ running: 'is-active',
48
+ 'awaiting-confirm': 'is-warning',
49
+ success: 'is-success',
50
+ error: 'is-danger',
51
+ rejected: 'is-danger'
51
52
  }
52
53
 
53
- const statusIcon = computed(() => STATUS_META[props.toolCall.status]?.icon ?? Loading)
54
- const statusClass = computed(() => STATUS_META[props.toolCall.status]?.class ?? '')
54
+ const statusClass = computed(() => STATUS_CLASS[props.toolCall.status] ?? '')
55
55
 
56
- /** 头部图标:工具自定义图标优先,缺省用状态图标(与工具卡片一致) */
57
- const headerIcon = computed(() => tool.value?.icon ?? statusIcon.value)
56
+ /** 头部图标:meta.icon > 宿主覆盖 > 内置名称规则 > 兜底;状态点走 status class */
57
+ const headerIcon = computed(
58
+ () => tool.value?.icon ?? resolveToolIcon(props.toolCall.name, di?.toolIcons?.value)
59
+ )
58
60
 
59
61
  /** 面板标题:优先工具定义的 panelTitle(业务对象 + 动作),缺省回落到 label ?? name */
60
62
  const panelTitle = computed(() => {