@veltra/ai 1.4.0

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 (71) hide show
  1. package/dist/chat/index.d.ts +5 -0
  2. package/dist/chat/transports/index.d.ts +2 -0
  3. package/dist/chat/transports/openai.d.ts +23 -0
  4. package/dist/chat/transports/openai.js +176 -0
  5. package/dist/chat/transports/openai.js.map +1 -0
  6. package/dist/chat/types.d.ts +125 -0
  7. package/dist/chat/use-chat.d.ts +25 -0
  8. package/dist/chat/use-chat.js +214 -0
  9. package/dist/chat/use-chat.js.map +1 -0
  10. package/dist/components/ai-chat/ai-chat.js +88 -0
  11. package/dist/components/ai-chat/ai-chat.js.map +1 -0
  12. package/dist/components/ai-chat/ai-chat.vue.d.ts +36 -0
  13. package/dist/components/ai-chat/ask-question.js +187 -0
  14. package/dist/components/ai-chat/ask-question.js.map +1 -0
  15. package/dist/components/ai-chat/chat-input.js +143 -0
  16. package/dist/components/ai-chat/chat-input.js.map +1 -0
  17. package/dist/components/ai-chat/di.js +6 -0
  18. package/dist/components/ai-chat/di.js.map +1 -0
  19. package/dist/components/ai-chat/index.d.ts +2 -0
  20. package/dist/components/ai-chat/message-item.js +120 -0
  21. package/dist/components/ai-chat/message-item.js.map +1 -0
  22. package/dist/components/ai-chat/message-list.js +75 -0
  23. package/dist/components/ai-chat/message-list.js.map +1 -0
  24. package/dist/components/ai-chat/style.d.ts +5 -0
  25. package/dist/components/ai-chat/style.js +6 -0
  26. package/dist/components/ai-chat/style2.css +416 -0
  27. package/dist/components/ai-chat/tool-call.js +151 -0
  28. package/dist/components/ai-chat/tool-call.js.map +1 -0
  29. package/dist/components/index.d.ts +3 -0
  30. package/dist/index.d.ts +11 -0
  31. package/dist/index.js +5 -0
  32. package/dist/style.d.ts +1 -0
  33. package/dist/style.js +1 -0
  34. package/dist/tools/ask-question/ask-question.d.ts +45 -0
  35. package/dist/tools/ask-question/ask-question.js +65 -0
  36. package/dist/tools/ask-question/ask-question.js.map +1 -0
  37. package/dist/tools/ask-question/deferred.js +22 -0
  38. package/dist/tools/ask-question/deferred.js.map +1 -0
  39. package/dist/tools/ask-question/index.d.ts +2 -0
  40. package/dist/tools/index.d.ts +3 -0
  41. package/dist/types/ai-chat.d.ts +49 -0
  42. package/dist/types/index.d.ts +2 -0
  43. package/package.json +40 -0
  44. package/src/chat/__test__/use-chat.test.ts +246 -0
  45. package/src/chat/index.ts +3 -0
  46. package/src/chat/transports/index.ts +1 -0
  47. package/src/chat/transports/openai.ts +237 -0
  48. package/src/chat/types.ts +136 -0
  49. package/src/chat/use-chat.ts +245 -0
  50. package/src/components/ai-chat/__test__/ai-chat.test.ts +316 -0
  51. package/src/components/ai-chat/ai-chat.vue +67 -0
  52. package/src/components/ai-chat/ask-question.vue +232 -0
  53. package/src/components/ai-chat/chat-input.vue +127 -0
  54. package/src/components/ai-chat/di.ts +15 -0
  55. package/src/components/ai-chat/index.ts +1 -0
  56. package/src/components/ai-chat/message-item.vue +120 -0
  57. package/src/components/ai-chat/message-list.vue +81 -0
  58. package/src/components/ai-chat/style.scss +439 -0
  59. package/src/components/ai-chat/style.ts +6 -0
  60. package/src/components/ai-chat/tool-call.vue +147 -0
  61. package/src/components/index.ts +1 -0
  62. package/src/env.d.ts +12 -0
  63. package/src/index.ts +4 -0
  64. package/src/style.ts +1 -0
  65. package/src/tools/ask-question/__test__/ask-question.test.ts +198 -0
  66. package/src/tools/ask-question/ask-question.ts +114 -0
  67. package/src/tools/ask-question/deferred.ts +27 -0
  68. package/src/tools/ask-question/index.ts +1 -0
  69. package/src/tools/index.ts +1 -0
  70. package/src/types/ai-chat.ts +56 -0
  71. package/src/types/index.ts +1 -0
@@ -0,0 +1,316 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createApp, defineComponent, h, nextTick, ref, type PropType } from 'vue'
3
+
4
+ import type { ChatTool, ChatToolCall, ChatTransport } from '../../../chat/types'
5
+ import type { AiChatExposed } from '../../../types'
6
+ import UAiChat from '../ai-chat.vue'
7
+
8
+ // happy-dom 环境下用桩组件替换 MarkdownRender,仅验证组件自身逻辑
9
+ vi.mock('markstream-vue', () => ({
10
+ default: defineComponent({
11
+ props: { content: { type: String, default: '' } },
12
+ setup(props) {
13
+ return () => h('div', { class: 'md-stub' }, props.content)
14
+ }
15
+ })
16
+ }))
17
+
18
+ function mountAiChat(options: {
19
+ transport: ChatTransport
20
+ tools?: ChatTool[]
21
+ welcome?: string
22
+ slots?: Record<string, (scope: any) => any>
23
+ }) {
24
+ const host = document.createElement('div')
25
+ document.body.appendChild(host)
26
+
27
+ const chatRef = ref<AiChatExposed>()
28
+
29
+ const app = createApp({
30
+ render() {
31
+ return h(
32
+ UAiChat,
33
+ {
34
+ ref: chatRef,
35
+ transport: options.transport,
36
+ tools: options.tools,
37
+ welcome: options.welcome
38
+ },
39
+ options.slots
40
+ )
41
+ }
42
+ })
43
+
44
+ app.mount(host)
45
+
46
+ return {
47
+ host,
48
+ chat: chatRef,
49
+ unmount() {
50
+ app.unmount()
51
+ host.remove()
52
+ }
53
+ }
54
+ }
55
+
56
+ describe('UAiChat', () => {
57
+ it('空状态显示欢迎语', () => {
58
+ const { host, unmount } = mountAiChat({ transport: () => {}, welcome: '有什么可以帮你?' })
59
+
60
+ expect(host.querySelector('.u-ai-chat__welcome')?.textContent).toContain('有什么可以帮你?')
61
+ unmount()
62
+ })
63
+
64
+ it('发送消息后渲染用户气泡与 assistant 回复', async () => {
65
+ const transport: ChatTransport = (_req, handlers) => {
66
+ handlers.onTextDelta('你好,我是助手')
67
+ }
68
+ const { host, chat, unmount } = mountAiChat({ transport })
69
+
70
+ chat.value?.send('你好')
71
+ await nextTick()
72
+
73
+ const userBubble = host.querySelector('.u-ai-chat__message--user .u-ai-chat__message-bubble')
74
+ expect(userBubble?.textContent).toContain('你好')
75
+
76
+ await vi.waitFor(() => {
77
+ expect(host.querySelector('.md-stub')?.textContent).toContain('你好,我是助手')
78
+ })
79
+ unmount()
80
+ })
81
+
82
+ it('工具调用渲染工具卡片,可展开查看结果', async () => {
83
+ let round = 0
84
+ const transport: ChatTransport = (_req, handlers) => {
85
+ round++
86
+ if (round === 1) {
87
+ handlers.onToolCall?.({ id: 'c1', name: 'calculate', arguments: '{"expression":"1+1"}' })
88
+ } else {
89
+ handlers.onTextDelta('算好了')
90
+ }
91
+ }
92
+ const tools: ChatTool[] = [
93
+ { name: 'calculate', description: '计算', parameters: {}, execute: () => ({ value: 2 }) }
94
+ ]
95
+ const { host, chat, unmount } = mountAiChat({ transport, tools })
96
+
97
+ chat.value?.send('1+1 等于几')
98
+
99
+ await vi.waitFor(() => {
100
+ expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('calculate')
101
+ })
102
+
103
+ // 执行完成后自动折叠,点击头部展开
104
+ await vi.waitFor(() => {
105
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
106
+ })
107
+
108
+ const header = host.querySelector<HTMLElement>('.u-ai-chat__tool-call .u-collapse__header')!
109
+ header.dispatchEvent(new MouseEvent('click', { bubbles: true }))
110
+ await nextTick()
111
+
112
+ const body = host.querySelector('.u-ai-chat__tool-call .u-collapse__content')
113
+ expect(body?.textContent).toContain('"expression": "1+1"')
114
+ expect(body?.textContent).toContain('"value": 2')
115
+ unmount()
116
+ })
117
+
118
+ it('needsConfirm 工具渲染确认按钮,点击允许后执行', async () => {
119
+ const transport: ChatTransport = (_req, handlers) => {
120
+ handlers.onToolCall?.({ id: 'c1', name: 'deleteFile', arguments: '{"path":"/tmp/a"}' })
121
+ }
122
+ const execute = vi.fn()
123
+ const tools: ChatTool[] = [
124
+ { name: 'deleteFile', description: '删除文件', parameters: {}, needsConfirm: true, execute }
125
+ ]
126
+ const { host, chat, unmount } = mountAiChat({ transport, tools })
127
+
128
+ chat.value?.send('删掉 /tmp/a')
129
+
130
+ await vi.waitFor(() => {
131
+ expect(host.querySelector('.u-ai-chat__tool-call-confirm')).toBeTruthy()
132
+ })
133
+ expect(execute).not.toHaveBeenCalled()
134
+
135
+ const allowBtn = [
136
+ ...host.querySelectorAll<HTMLElement>('.u-ai-chat__tool-call-confirm button')
137
+ ].find((btn) => btn.textContent?.includes('允许'))!
138
+ allowBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
139
+
140
+ await vi.waitFor(() => {
141
+ expect(execute).toHaveBeenCalled()
142
+ })
143
+ unmount()
144
+ })
145
+
146
+ it('工具支持自定义 icon 与 label', async () => {
147
+ let round = 0
148
+ const transport: ChatTransport = (_req, handlers) => {
149
+ round++
150
+ if (round === 1) {
151
+ handlers.onToolCall?.({ id: 'c1', name: 'search', arguments: '{"q":"vue"}' })
152
+ } else {
153
+ handlers.onTextDelta('搜到了')
154
+ }
155
+ }
156
+ const CustomIcon = defineComponent({
157
+ setup: () => () => h('svg', { class: 'custom-tool-icon' })
158
+ })
159
+ const tools: ChatTool[] = [
160
+ {
161
+ name: 'search',
162
+ label: '搜索',
163
+ icon: CustomIcon,
164
+ description: '搜索',
165
+ parameters: {},
166
+ execute: () => ({ hits: 3 })
167
+ }
168
+ ]
169
+ const { host, chat, unmount } = mountAiChat({ transport, tools })
170
+
171
+ chat.value?.send('搜一下 vue')
172
+
173
+ await vi.waitFor(() => {
174
+ expect(host.querySelector('.u-ai-chat__tool-call-name')?.textContent).toBe('搜索')
175
+ expect(host.querySelector('.custom-tool-icon')).toBeTruthy()
176
+ })
177
+ unmount()
178
+ })
179
+
180
+ /** 渲染 toolCall.result 的自定义视图桩 */
181
+ const createResultView = (className: string) =>
182
+ defineComponent({
183
+ props: { toolCall: { type: Object as PropType<ChatToolCall>, required: true } },
184
+ setup(props) {
185
+ return () => h('div', { class: className }, `result:${props.toolCall.result ?? ''}`)
186
+ }
187
+ })
188
+
189
+ /** 第一轮发起 getWeather 工具调用,第二轮输出文本 */
190
+ const createWeatherTransport = () => {
191
+ let round = 0
192
+ const transport: ChatTransport = (_req, handlers) => {
193
+ round++
194
+ if (round === 1) {
195
+ handlers.onToolCall?.({ id: 'c1', name: 'getWeather', arguments: '{"city":"北京"}' })
196
+ } else {
197
+ handlers.onTextDelta('查好了')
198
+ }
199
+ }
200
+ return transport
201
+ }
202
+
203
+ const weatherTool = (extra: Partial<ChatTool> = {}): ChatTool => ({
204
+ name: 'getWeather',
205
+ description: '查天气',
206
+ parameters: {},
207
+ execute: () => ({ temperature: 26 }),
208
+ ...extra
209
+ })
210
+
211
+ it('render 自定义渲染替换卡片 body,完成后保持展开', async () => {
212
+ const tools = [weatherTool({ render: createResultView('weather-view') })]
213
+ const { host, chat, unmount } = mountAiChat({ transport: createWeatherTransport(), tools })
214
+
215
+ chat.value?.send('北京天气')
216
+
217
+ // running 时 render 即渲染(render 工具默认不折叠)
218
+ await vi.waitFor(() => {
219
+ expect(host.querySelector('.weather-view')).toBeTruthy()
220
+ })
221
+
222
+ // 完成后保持展开,默认参数/结果区被替换
223
+ await vi.waitFor(() => {
224
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-expanded')).toBeTruthy()
225
+ })
226
+ expect(host.querySelector('.weather-view')?.textContent).toContain('{"temperature":26}')
227
+ expect(host.querySelector('.u-ai-chat__tool-call-code')).toBeFalsy()
228
+ unmount()
229
+ })
230
+
231
+ it('autoCollapse: false 的普通工具完成后保持展开', async () => {
232
+ let round = 0
233
+ const transport: ChatTransport = (_req, handlers) => {
234
+ round++
235
+ if (round === 1) {
236
+ handlers.onToolCall?.({ id: 'c1', name: 'calculate', arguments: '{"expression":"1+1"}' })
237
+ } else {
238
+ handlers.onTextDelta('算好了')
239
+ }
240
+ }
241
+ const tools: ChatTool[] = [
242
+ {
243
+ name: 'calculate',
244
+ description: '计算',
245
+ parameters: {},
246
+ autoCollapse: false,
247
+ execute: () => ({ value: 2 })
248
+ }
249
+ ]
250
+ const { host, chat, unmount } = mountAiChat({ transport, tools })
251
+
252
+ chat.value?.send('1+1')
253
+
254
+ await vi.waitFor(() => {
255
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success.is-expanded')).toBeTruthy()
256
+ })
257
+ expect(host.querySelector('.u-ai-chat__tool-call .u-collapse__content')?.textContent).toContain(
258
+ '"value": 2'
259
+ )
260
+ unmount()
261
+ })
262
+
263
+ it('render 工具设置 autoCollapse: true 后完成即折叠', async () => {
264
+ const tools = [weatherTool({ render: createResultView('weather-view'), autoCollapse: true })]
265
+ const { host, chat, unmount } = mountAiChat({ transport: createWeatherTransport(), tools })
266
+
267
+ chat.value?.send('北京天气')
268
+
269
+ await vi.waitFor(() => {
270
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
271
+ })
272
+ await nextTick()
273
+ expect(host.querySelector('.u-ai-chat__tool-call.is-expanded')).toBeFalsy()
274
+ const contentWrapper = host.querySelector('.u-ai-chat__tool-call .u-collapse__content-wrapper')
275
+ expect(contentWrapper?.getAttribute('aria-hidden')).toBe('true')
276
+ unmount()
277
+ })
278
+
279
+ it('render 优先于 tool-<name> 插槽', async () => {
280
+ const tools = [weatherTool({ render: createResultView('weather-view') })]
281
+ const { host, chat, unmount } = mountAiChat({
282
+ transport: createWeatherTransport(),
283
+ tools,
284
+ slots: { 'tool-getWeather': () => h('div', { class: 'slot-view' }, 'slot') }
285
+ })
286
+
287
+ chat.value?.send('北京天气')
288
+
289
+ await vi.waitFor(() => {
290
+ expect(host.querySelector('.u-ai-chat__tool-call.is-success')).toBeTruthy()
291
+ })
292
+ expect(host.querySelector('.weather-view')).toBeTruthy()
293
+ expect(host.querySelector('.slot-view')).toBeFalsy()
294
+ unmount()
295
+ })
296
+
297
+ it('生成中输入区显示停止按钮', async () => {
298
+ const transport: ChatTransport = (req) => {
299
+ return new Promise<void>((resolve) => {
300
+ req.signal.addEventListener('abort', () => resolve(), { once: true })
301
+ })
302
+ }
303
+ const { host, chat, unmount } = mountAiChat({ transport })
304
+
305
+ chat.value?.send('hi')
306
+ await nextTick()
307
+
308
+ expect(host.querySelector('.u-ai-chat__input-stop')).toBeTruthy()
309
+
310
+ chat.value?.abort()
311
+ await vi.waitFor(() => {
312
+ expect(host.querySelector('.u-ai-chat__input-stop')).toBeFalsy()
313
+ })
314
+ unmount()
315
+ })
316
+ })
@@ -0,0 +1,67 @@
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
+ :running="running"
17
+ :placeholder="placeholder"
18
+ :accept="accept"
19
+ :max-attachment-size="maxAttachmentSize"
20
+ @send="send"
21
+ @abort="abort"
22
+ />
23
+ </div>
24
+ </template>
25
+
26
+ <script lang="ts" setup>
27
+ import { bem } from '@veltra/utils'
28
+ import { computed, provide, useSlots } from 'vue'
29
+
30
+ import type { ChatTool, ChatToolCall } from '../../chat/types'
31
+ import { useChat } from '../../chat/use-chat'
32
+ import type { _AiChatExposed, AiChatEmits, AiChatProps } from '../../types'
33
+ import ChatInput from './chat-input.vue'
34
+ import { AiChatDIKey } from './di'
35
+ import MessageList from './message-list.vue'
36
+
37
+ defineOptions({ name: 'UAiChat' })
38
+
39
+ const props = defineProps<AiChatProps>()
40
+
41
+ const emit = defineEmits<AiChatEmits>()
42
+
43
+ defineSlots<{
44
+ /** 空状态欢迎区插槽 */
45
+ welcome(): any
46
+ /** 按工具名自定义工具结果展示,如 tool-getWeather */
47
+ [name: `tool-${string}`]: (scope: { toolCall: ChatToolCall }) => any
48
+ }>()
49
+
50
+ const cls = bem('ai-chat')
51
+
52
+ const slots = useSlots()
53
+
54
+ /** 按 name 索引的工具定义,供工具卡片解析 icon/label/render/autoCollapse */
55
+ const toolMap = computed<Record<string, ChatTool | undefined>>(() => {
56
+ return Object.fromEntries((props.tools ?? []).map((tool) => [tool.name, tool]))
57
+ })
58
+
59
+ provide(AiChatDIKey, { cls, slots, tools: toolMap })
60
+
61
+ const { messages, running, send, abort, regenerate, clear, respondToolCall } = useChat({
62
+ props,
63
+ emit
64
+ })
65
+
66
+ defineExpose<_AiChatExposed>({ send, abort, regenerate, clear })
67
+ </script>
@@ -0,0 +1,232 @@
1
+ <template>
2
+ <div :class="cls.e('ask-question')">
3
+ <!-- 作答中:分页向导 -->
4
+ <template v-if="answering">
5
+ <div v-if="questions.length > 1" :class="cls.e('ask-question-progress')">
6
+ <span :class="cls.e('ask-question-count')">{{ current + 1 }} / {{ questions.length }}</span>
7
+ <span :class="cls.e('ask-question-dots')">
8
+ <span
9
+ v-for="(_, i) in questions"
10
+ :key="i"
11
+ :class="[
12
+ cls.e('ask-question-dot'),
13
+ bem.is('current', i === current),
14
+ bem.is('answered', isAnswered(i))
15
+ ]"
16
+ @click="goTo(i)"
17
+ />
18
+ </span>
19
+ </div>
20
+
21
+ <div :class="cls.e('ask-question-q')">
22
+ <span v-if="questions.length > 1" :class="cls.e('ask-question-no')" aria-hidden="true">
23
+ {{ current + 1 }}
24
+ </span>
25
+ {{ currentQuestion?.question }}
26
+ </div>
27
+
28
+ <div
29
+ v-if="currentQuestion?.options?.length"
30
+ :class="cls.e('ask-question-options')"
31
+ role="radiogroup"
32
+ >
33
+ <button
34
+ v-for="option in currentQuestion.options"
35
+ :key="option"
36
+ type="button"
37
+ role="radio"
38
+ :aria-checked="selections[current] === option"
39
+ :class="[cls.e('ask-question-opt'), bem.is('selected', selections[current] === option)]"
40
+ @click="pickOption(option)"
41
+ >
42
+ <span
43
+ :class="[
44
+ cls.e('ask-question-glyph'),
45
+ bem.is('selected', selections[current] === option)
46
+ ]"
47
+ aria-hidden="true"
48
+ />
49
+ <span :class="cls.e('ask-question-opt-text')">{{ option }}</span>
50
+ </button>
51
+ </div>
52
+
53
+ <UInput
54
+ :model-value="customs[current]"
55
+ :placeholder="currentQuestion?.placeholder ?? '自定义回答…'"
56
+ @update:model-value="onCustomInput"
57
+ />
58
+
59
+ <footer :class="cls.e('ask-question-actions')">
60
+ <UButton
61
+ v-if="questions.length > 1"
62
+ size="small"
63
+ text
64
+ :disabled="current === 0"
65
+ @click="goTo(current - 1)"
66
+ >
67
+ 上一个
68
+ </UButton>
69
+ <UButton
70
+ v-if="current < questions.length - 1"
71
+ size="small"
72
+ type="primary"
73
+ @click="goTo(current + 1)"
74
+ >
75
+ 下一个
76
+ </UButton>
77
+ <UButton v-else size="small" type="primary" :disabled="!allAnswered" @click="handleSubmit">
78
+ 提交
79
+ </UButton>
80
+ </footer>
81
+ </template>
82
+
83
+ <!-- 提交后:问答摘要 -->
84
+ <template v-else-if="toolCall.status === 'success'">
85
+ <div v-for="(item, i) in doneAnswers" :key="i" :class="cls.e('ask-question-result')">
86
+ <div :class="cls.e('ask-question-result-q')">
87
+ <span
88
+ v-if="doneAnswers.length > 1"
89
+ :class="cls.e('ask-question-result-no')"
90
+ aria-hidden="true"
91
+ >
92
+ {{ i + 1 }}
93
+ </span>
94
+ {{ item.question }}
95
+ </div>
96
+ <div :class="cls.e('ask-question-result-a')">
97
+ <span v-if="item.isOption" :class="cls.e('ask-question-chip')">{{ item.answer }}</span>
98
+ <template v-else>{{ item.answer }}</template>
99
+ </div>
100
+ </div>
101
+ </template>
102
+
103
+ <!-- 异常 / 取消 -->
104
+ <div v-else :class="cls.e('ask-question-status')">{{ statusText }}</div>
105
+ </div>
106
+ </template>
107
+
108
+ <script lang="ts" setup>
109
+ import { UButton, UInput } from '@veltra/desktop'
110
+ import { bem } from '@veltra/utils'
111
+ import { computed, inject, ref, watch } from 'vue'
112
+
113
+ import type { ChatToolCall } from '../../chat/types'
114
+ import type {
115
+ AskQuestionArgs,
116
+ AskQuestionItem,
117
+ AskQuestionResult
118
+ } from '../../tools/ask-question/ask-question'
119
+ import { resolveAskQuestion } from '../../tools/ask-question/deferred'
120
+ import { AiChatDIKey } from './di'
121
+
122
+ defineOptions({ name: 'UAiChatAskQuestion' })
123
+
124
+ const props = defineProps<{ toolCall: ChatToolCall }>()
125
+
126
+ const di = inject(AiChatDIKey)
127
+ const cls = di?.cls ?? bem('ai-chat')
128
+
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
+ })
146
+
147
+ /** 当前题索引 */
148
+ const current = ref(0)
149
+ /** 各题选中的预设选项 */
150
+ const selections = ref<(string | null)[]>([])
151
+ /** 各题的自定义输入 */
152
+ const customs = ref<string[]>([])
153
+
154
+ watch(
155
+ questions,
156
+ (list) => {
157
+ if (props.toolCall.result != null) return
158
+ selections.value = list.map(() => null)
159
+ customs.value = list.map(() => '')
160
+ },
161
+ { immediate: true }
162
+ )
163
+
164
+ const currentQuestion = computed(() => questions.value[current.value])
165
+
166
+ /** 某题是否已作答:自定义输入非空优先,否则看选中项 */
167
+ const isAnswered = (index: number) => {
168
+ return Boolean(customs.value[index]?.trim() || selections.value[index])
169
+ }
170
+
171
+ const allAnswered = computed(() => {
172
+ return questions.value.length > 0 && questions.value.every((_, i) => isAnswered(i))
173
+ })
174
+
175
+ const goTo = (index: number) => {
176
+ if (index < 0 || index >= questions.value.length) return
177
+ current.value = index
178
+ }
179
+
180
+ /** 点选选项:再次点击取消选择;选中时清空自定义输入(两者互斥) */
181
+ const pickOption = (option: string) => {
182
+ const i = current.value
183
+ const selected = selections.value[i] === option ? null : option
184
+ selections.value[i] = selected
185
+ if (selected) customs.value[i] = ''
186
+ }
187
+
188
+ /** 自定义输入:有内容时清除已选选项(两者互斥) */
189
+ const onCustomInput = (value: string) => {
190
+ customs.value[current.value] = value
191
+ if (value) selections.value[current.value] = null
192
+ }
193
+
194
+ const handleSubmit = () => {
195
+ const firstUnanswered = questions.value.findIndex((_, i) => !isAnswered(i))
196
+ if (firstUnanswered >= 0) {
197
+ current.value = firstUnanswered
198
+ return
199
+ }
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 })
205
+ }
206
+
207
+ /** 提交后的问答摘要(含历史恢复:从序列化结果解析) */
208
+ 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(
221
+ (q) => q.question === item.question && q.options?.includes(item.answer)
222
+ )
223
+ }))
224
+ })
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
+ </script>