@veltra/ai 2.0.0 → 2.1.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 (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 +83 -8
  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 +224 -9
  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 +112 -9
  47. package/src/components/ai-chat/__test__/ai-chat.test.ts +588 -11
  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,8 +1,12 @@
1
1
  import '@veltra/desktop/components/button/style'
2
+ import '@veltra/desktop/components/collapse/style'
2
3
  import '@veltra/desktop/components/file-picker/style'
3
4
  import '@veltra/desktop/components/icon/style'
4
5
  import '@veltra/desktop/components/input/style'
6
+ import '@veltra/desktop/components/layout/style'
7
+ import '@veltra/desktop/components/message/style'
5
8
  import '@veltra/desktop/components/scroll/style'
6
9
  import '@veltra/desktop/components/select/style'
10
+ import '@veltra/styles/animations/shine.scss'
7
11
  import 'markstream-vue/index.css'
8
12
  import './style.scss'
@@ -1,47 +1,69 @@
1
1
  <template>
2
- <div :class="[cls.e('tool-call'), bem.is(toolCall.status), bem.is('expanded', isExpanded)]">
3
- <UCollapseItem v-model="isExpanded" @change="markUserToggled">
4
- <template #title>
5
- <UIcon :class="[cls.e('tool-call-status'), statusClass]">
6
- <component :is="headerIcon" />
7
- </UIcon>
8
- <span :class="cls.e('tool-call-name')">{{ tool?.label ?? toolCall.name }}</span>
9
- <span v-if="summary" :class="cls.e('tool-call-summary')">{{ summary }}</span>
10
-
11
- <span
12
- v-if="toolCall.status === 'awaiting-confirm'"
13
- :class="cls.e('tool-call-confirm')"
14
- @click.stop
15
- >
16
- <UButton size="small" type="primary" @click="emit('respond', true)">允许</UButton>
17
- <UButton size="small" text @click="emit('respond', false)">拒绝</UButton>
18
- </span>
2
+ <UCollapseItem
3
+ v-model="isExpanded"
4
+ :class="[cls.e('tool-call'), bem.is(toolCall.status)]"
5
+ :destroy-on-collapse="destroyOnCollapse"
6
+ @change="markUserToggled"
7
+ >
8
+ <template #header>
9
+ <UIcon :class="[cls.e('tool-call-status'), statusClass]">
10
+ <component :is="headerIcon" />
11
+ </UIcon>
12
+ <span :class="[cls.e('tool-call-name'), isActive && 'u-shine']">
13
+ {{ tool?.label ?? toolCall.name }}
14
+ </span>
15
+ <span v-if="summary" :class="cls.e('tool-call-summary')">{{ summary }}</span>
16
+
17
+ <span
18
+ v-if="toolCall.status === 'awaiting-confirm'"
19
+ :class="cls.e('tool-call-confirm')"
20
+ @click.stop
21
+ @keydown.enter.stop
22
+ @keydown.space.stop
23
+ >
24
+ <UButton size="small" type="primary" @click="emit('respond', true)">允许</UButton>
25
+ <UButton size="small" text @click="emit('respond', false)">拒绝</UButton>
26
+ </span>
27
+
28
+ <UIcon :class="cls.e('tool-call-chevron')"><ArrowDown /></UIcon>
29
+ </template>
30
+
31
+ <!-- 面板工具:render 展示在右侧侧边面板,卡片 body 仅提供查看入口 -->
32
+ <div v-if="isPanelTool" :class="cls.e('tool-call-panel-entry')">
33
+ <span :class="cls.e('tool-call-panel-hint')">内容展示在右侧面板</span>
34
+ <UButton size="small" text type="primary" @click="di?.openPanel(toolCall.id)">
35
+ 查看面板
36
+ </UButton>
37
+ </div>
38
+ <!-- 自定义渲染:工具定义的 render 优先于 tool-<name> 插槽,均替换整个 body -->
39
+ <component :is="tool.render" v-else-if="tool?.render" :tool-call="toolCall" />
40
+ <component :is="slotBody" v-else-if="slotBody && hasResult" />
41
+ <template v-else>
42
+ <template v-if="prettyArguments">
43
+ <div :class="cls.e('tool-call-section')">参数</div>
44
+ <pre :class="cls.e('tool-call-code')">{{ prettyArguments }}</pre>
19
45
  </template>
20
46
 
21
- <!-- 自定义渲染:工具定义的 render 优先于 tool-<name> 插槽,均替换整个 body -->
22
- <component :is="tool?.render" v-if="tool?.render" :tool-call="toolCall" />
23
- <component :is="slotBody" v-else-if="slotBody && hasResult" />
24
- <template v-else>
25
- <template v-if="prettyArguments">
26
- <div :class="cls.e('tool-call-section')">参数</div>
27
- <pre :class="cls.e('tool-call-code')">{{ prettyArguments }}</pre>
28
- </template>
29
-
30
- <template v-if="hasResult">
31
- <div :class="cls.e('tool-call-section')">
32
- {{ toolCall.status === 'error' ? '错误' : '结果' }}
33
- </div>
34
- <pre :class="cls.e('tool-call-code')">{{ displayResult }}</pre>
35
- </template>
47
+ <template v-if="hasResult">
48
+ <div :class="cls.e('tool-call-section')">
49
+ {{ toolCall.status === 'error' ? '错误' : '结果' }}
50
+ </div>
51
+ <pre :class="cls.e('tool-call-code')">{{ displayResult }}</pre>
36
52
  </template>
37
- </UCollapseItem>
38
- </div>
53
+ </template>
54
+ </UCollapseItem>
39
55
  </template>
40
56
 
41
57
  <script lang="ts" setup>
42
58
  import { UButton, UCollapseItem, UIcon } from '@veltra/desktop'
43
- import '@veltra/desktop/components/collapse/style'
44
- import { CircleCheckFilled, CircleClose, Close, Loading, WarningFilled } from '@veltra/icons/normal'
59
+ import {
60
+ ArrowDown,
61
+ CircleCheckFilled,
62
+ CircleClose,
63
+ Close,
64
+ Loading,
65
+ WarningFilled
66
+ } from '@veltra/icons/normal'
45
67
  import { bem } from '@veltra/utils'
46
68
  import { computed, inject, ref, watch, type Component } from 'vue'
47
69
 
@@ -57,19 +79,25 @@ const emit = defineEmits<{ (e: 'respond', approved: boolean): void }>()
57
79
  const di = inject(AiChatDIKey)
58
80
  const cls = di?.cls ?? bem('ai-chat')
59
81
 
60
- /** 当前调用对应的工具定义(解析 icon/label/render/autoCollapse) */
82
+ /** 当前调用对应的工具定义(解析 icon/label/render/autoCollapse/terminal) */
61
83
  const tool = computed(() => di?.tools.value[props.toolCall.name])
62
84
 
63
85
  const isExpanded = ref(false)
64
86
  /** 用户手动切换过后,不再随 status 自动展开/折叠 */
65
87
  let userToggled: boolean = false
66
88
 
67
- function markUserToggled() {
89
+ /** UCollapseItem 的 change 仅在用户点击头部时触发(程序化 v-model 变更不触发) */
90
+ const markUserToggled = () => {
68
91
  userToggled = true
69
92
  }
70
93
 
71
- /** 完成后是否自动折叠:缺省有 render 时不折叠,否则折叠 */
72
- const autoCollapse = computed(() => tool.value?.autoCollapse ?? !tool.value?.render)
94
+ /** 面板工具:render 展示在右侧侧边面板,卡片 body 仅保留「查看面板」入口 */
95
+ const isPanelTool = computed(() => tool.value?.renderTo === 'panel')
96
+
97
+ /** 完成后是否自动折叠:缺省面板工具折叠;有 render 时不折叠,否则折叠 */
98
+ const autoCollapse = computed(
99
+ () => tool.value?.autoCollapse ?? (isPanelTool.value || !tool.value?.render)
100
+ )
73
101
 
74
102
  watch(
75
103
  () => props.toolCall.status,
@@ -85,8 +113,8 @@ watch(
85
113
  )
86
114
 
87
115
  const STATUS_META: Record<string, { icon: Component; class: string }> = {
88
- pending: { icon: Loading, class: 'is-loading' },
89
- running: { icon: Loading, class: 'is-loading' },
116
+ pending: { icon: Loading, class: 'is-active' },
117
+ running: { icon: Loading, class: 'is-active' },
90
118
  'awaiting-confirm': { icon: WarningFilled, class: 'is-warning' },
91
119
  success: { icon: CircleCheckFilled, class: 'is-success' },
92
120
  error: { icon: CircleClose, class: 'is-danger' },
@@ -96,6 +124,22 @@ const STATUS_META: Record<string, { icon: Component; class: string }> = {
96
124
  const statusIcon = computed(() => STATUS_META[props.toolCall.status]?.icon ?? Loading)
97
125
  const statusClass = computed(() => STATUS_META[props.toolCall.status]?.class ?? '')
98
126
 
127
+ /** 进行中(pending/running):工具名带扫光效果 */
128
+ const isActive = computed(() => {
129
+ return props.toolCall.status === 'pending' || props.toolCall.status === 'running'
130
+ })
131
+
132
+ /** 终态(成功/失败/拒绝) */
133
+ const isSettled = computed(() => {
134
+ return ['success', 'error', 'rejected'].includes(props.toolCall.status)
135
+ })
136
+
137
+ /**
138
+ * 折叠后卸载内容 DOM:仅终态且非面板工具时启用——
139
+ * 进行中/待确认要保留内容状态(确认按钮、表单),面板工具的 body 是「查看面板」入口必须可达
140
+ */
141
+ const destroyOnCollapse = computed(() => isSettled.value && !isPanelTool.value)
142
+
99
143
  /** 头部图标:工具自定义图标优先,缺省用状态图标 */
100
144
  const headerIcon = computed(() => tool.value?.icon ?? statusIcon.value)
101
145
 
@@ -0,0 +1,251 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createApp, h, nextTick } from 'vue'
3
+
4
+ import UAiOrb from '../ai-orb.vue'
5
+ import { createOrbRenderer } from '../orb-renderer'
6
+
7
+ /** 记录调用的 2D context 桩(happy-dom 无真实 canvas 实现) */
8
+ function createStubContext() {
9
+ const calls = { fill: 0, clip: 0, gradient: 0, stroke: 0 }
10
+ const gradient = { addColorStop: () => {} }
11
+ const ctx = {
12
+ calls,
13
+ fillStyle: null as unknown,
14
+ strokeStyle: null as unknown,
15
+ lineWidth: 1,
16
+ lineCap: 'butt',
17
+ lineJoin: 'miter',
18
+ globalAlpha: 1,
19
+ globalCompositeOperation: 'source-over',
20
+ save: () => {},
21
+ restore: () => {},
22
+ translate: () => {},
23
+ scale: () => {},
24
+ rotate: () => {},
25
+ beginPath: () => {},
26
+ moveTo: () => {},
27
+ lineTo: () => {},
28
+ quadraticCurveTo: () => {},
29
+ closePath: () => {},
30
+ arc: () => {},
31
+ ellipse: () => {},
32
+ clearRect: () => {},
33
+ fillRect: () => {},
34
+ setTransform: () => {},
35
+ stroke: () => {
36
+ calls.stroke++
37
+ },
38
+ fill: () => {
39
+ calls.fill++
40
+ },
41
+ clip: () => {
42
+ calls.clip++
43
+ },
44
+ createRadialGradient: () => {
45
+ calls.gradient++
46
+ return gradient
47
+ }
48
+ }
49
+ return ctx
50
+ }
51
+
52
+ function createStubCanvas(ctx: ReturnType<typeof createStubContext> | null) {
53
+ return { width: 0, height: 0, getContext: () => ctx } as unknown as HTMLCanvasElement
54
+ }
55
+
56
+ /** 桩 Path2D(happy-dom 未实现) */
57
+ function stubPath2D() {
58
+ class Path2DStub {
59
+ moveTo() {}
60
+ lineTo() {}
61
+ closePath() {}
62
+ }
63
+ vi.stubGlobal('Path2D', Path2DStub)
64
+ }
65
+
66
+ describe('createOrbRenderer', () => {
67
+ beforeEach(() => {
68
+ stubPath2D()
69
+ })
70
+
71
+ afterEach(() => {
72
+ vi.unstubAllGlobals()
73
+ vi.restoreAllMocks()
74
+ })
75
+
76
+ it('renderOnce 绘制一帧(纯色球体 + 白色大眼睛,常态无嘴)', () => {
77
+ const ctx = createStubContext()
78
+ const renderer = createOrbRenderer(createStubCanvas(ctx), { size: 48 })
79
+
80
+ renderer.renderOnce()
81
+
82
+ // 球体 + 双眼 = 3 次填充,常态无嘴无描边
83
+ expect(ctx.calls.fill).toBe(3)
84
+ expect(ctx.calls.stroke).toBe(0)
85
+ expect(ctx.calls.gradient).toBe(0)
86
+ })
87
+
88
+ it('start/stop 控制 rAF 循环', () => {
89
+ const ctx = createStubContext()
90
+ const frames: ((now: number) => void)[] = []
91
+ vi.stubGlobal('requestAnimationFrame', (cb: (now: number) => void) => {
92
+ frames.push(cb)
93
+ return frames.length
94
+ })
95
+ const cancel = vi.fn()
96
+ vi.stubGlobal('cancelAnimationFrame', cancel)
97
+
98
+ const renderer = createOrbRenderer(createStubCanvas(ctx), { size: 48 })
99
+ expect(renderer.running).toBe(false)
100
+
101
+ renderer.start()
102
+ expect(renderer.running).toBe(true)
103
+
104
+ // 手动驱动两帧
105
+ frames.shift()?.(16)
106
+ frames.shift()?.(32)
107
+ expect(ctx.calls.fill).toBeGreaterThan(0)
108
+
109
+ renderer.stop()
110
+ expect(renderer.running).toBe(false)
111
+ expect(cancel).toHaveBeenCalled()
112
+
113
+ // 已排队的帧回调执行后不再调度新帧
114
+ const pending = frames.length
115
+ frames.forEach((cb) => cb(48))
116
+ expect(frames.length).toBe(pending)
117
+ })
118
+
119
+ it('状态切换平滑过渡且无异常', () => {
120
+ const ctx = createStubContext()
121
+ const renderer = createOrbRenderer(createStubCanvas(ctx), { size: 48, status: 'idle' })
122
+
123
+ renderer.setStatus('thinking')
124
+ renderer.renderOnce()
125
+ renderer.setStatus('speaking')
126
+ renderer.renderOnce()
127
+
128
+ expect(ctx.calls.fill).toBeGreaterThan(0)
129
+ })
130
+
131
+ it('resize 更新画布像素尺寸(dpr 上限 2)', () => {
132
+ const ctx = createStubContext()
133
+ const canvas = createStubCanvas(ctx)
134
+ const renderer = createOrbRenderer(canvas, { size: 48 })
135
+
136
+ expect(canvas.width).toBe(48)
137
+
138
+ renderer.resize(96)
139
+ expect(canvas.width).toBe(96)
140
+ })
141
+
142
+ it('trigger 瞬时表情改变绘制路径(frustrated 闭眼折线 + 撇嘴)', () => {
143
+ const ctx = createStubContext()
144
+ const renderer = createOrbRenderer(createStubCanvas(ctx), { size: 48 })
145
+
146
+ // 常态:眼睛为填充圆眼,无嘴无描边
147
+ renderer.renderOnce()
148
+ const baseStrokes = ctx.calls.stroke
149
+ expect(baseStrokes).toBe(0)
150
+
151
+ // frustrated:双眼 >< 折线 2 次描边 + 撇嘴 1 次描边
152
+ renderer.trigger('frustrated')
153
+ expect(ctx.calls.stroke).toBeGreaterThan(baseStrokes)
154
+ })
155
+
156
+ it('trigger happy 弯眼弧线 + 张嘴笑', () => {
157
+ const ctx = createStubContext()
158
+ const renderer = createOrbRenderer(createStubCanvas(ctx), { size: 48 })
159
+
160
+ renderer.renderOnce()
161
+ const baseStrokes = ctx.calls.stroke
162
+
163
+ // happy:双眼弯月弧线 2 次描边 + 张嘴笑 1 次填充
164
+ renderer.trigger('happy')
165
+ expect(ctx.calls.stroke).toBeGreaterThan(baseStrokes)
166
+ })
167
+
168
+ it('trigger shock 睁大眼睛(填充路径增多)', () => {
169
+ const ctx = createStubContext()
170
+ const renderer = createOrbRenderer(createStubCanvas(ctx), { size: 48 })
171
+
172
+ renderer.renderOnce()
173
+ const baseFills = ctx.calls.fill
174
+
175
+ // shock:圆眼 + 「o」嘴,填充次数增加(无描边嘴)
176
+ renderer.trigger('shock')
177
+ expect(ctx.calls.fill).toBeGreaterThan(baseFills)
178
+ })
179
+
180
+ it('setPointer / poke 交互不抛异常且保持绘制', () => {
181
+ const ctx = createStubContext()
182
+ const renderer = createOrbRenderer(createStubCanvas(ctx), { size: 48 })
183
+
184
+ renderer.setPointer({ x: 0.8, y: -0.4 })
185
+ renderer.poke()
186
+ renderer.renderOnce()
187
+ expect(ctx.calls.fill).toBeGreaterThan(0)
188
+
189
+ renderer.setPointer(null)
190
+ renderer.renderOnce()
191
+ expect(ctx.calls.fill).toBeGreaterThan(3)
192
+ })
193
+
194
+ it('getContext 返回 null 时所有操作静默不崩', () => {
195
+ const renderer = createOrbRenderer(createStubCanvas(null), { size: 48 })
196
+
197
+ expect(() => {
198
+ renderer.renderOnce()
199
+ renderer.start()
200
+ renderer.setStatus('thinking')
201
+ renderer.trigger('happy')
202
+ renderer.resize(24)
203
+ renderer.stop()
204
+ }).not.toThrow()
205
+ expect(renderer.running).toBe(false)
206
+ })
207
+ })
208
+
209
+ describe('UAiOrb', () => {
210
+ afterEach(() => {
211
+ vi.unstubAllGlobals()
212
+ })
213
+
214
+ it('挂载渲染 canvas 并应用尺寸样式(无 2D 环境不崩)', async () => {
215
+ // happy-dom 的 getContext 无真实实现,显式桩为 null
216
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
217
+
218
+ const host = document.createElement('div')
219
+ document.body.appendChild(host)
220
+
221
+ const app = createApp({ render: () => h(UAiOrb, { size: 64, status: 'thinking' }) })
222
+ app.mount(host)
223
+ await nextTick()
224
+
225
+ const canvas = host.querySelector('canvas.u-ai-orb')
226
+ expect(canvas).toBeTruthy()
227
+ expect(canvas?.style.width).toBe('64px')
228
+ expect(canvas?.style.height).toBe('64px')
229
+
230
+ app.unmount()
231
+ host.remove()
232
+ })
233
+
234
+ it('点击 canvas 触发 click 事件', async () => {
235
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null)
236
+
237
+ const onClick = vi.fn()
238
+ const host = document.createElement('div')
239
+ document.body.appendChild(host)
240
+
241
+ const app = createApp({ render: () => h(UAiOrb, { onClick }) })
242
+ app.mount(host)
243
+ await nextTick()
244
+
245
+ host.querySelector('canvas.u-ai-orb')!.dispatchEvent(new Event('click'))
246
+ expect(onClick).toHaveBeenCalledTimes(1)
247
+
248
+ app.unmount()
249
+ host.remove()
250
+ })
251
+ })
@@ -0,0 +1,107 @@
1
+ <template>
2
+ <canvas
3
+ ref="canvasRef"
4
+ :class="cls.b"
5
+ :style="{ width: `${size}px`, height: `${size}px` }"
6
+ aria-hidden="true"
7
+ @pointermove="handlePointerMove"
8
+ @pointerleave="handlePointerLeave"
9
+ @pointerdown="handlePointerDown"
10
+ @click="emit('click')"
11
+ />
12
+ </template>
13
+
14
+ <script lang="ts" setup>
15
+ import { bem } from '@veltra/utils'
16
+ import { onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
17
+
18
+ import type { AiOrbEmits, AiOrbExposed, AiOrbProps, AiOrbReaction } from '../../types/ai-orb'
19
+ import { createOrbRenderer, type AiOrbRenderer } from './orb-renderer'
20
+
21
+ defineOptions({ name: 'UAiOrb' })
22
+
23
+ const props = withDefaults(defineProps<AiOrbProps>(), { size: 48, status: 'idle' })
24
+ const emit = defineEmits<AiOrbEmits>()
25
+
26
+ const cls = bem('ai-orb')
27
+
28
+ const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
29
+
30
+ let renderer: AiOrbRenderer | null = null
31
+ let observer: IntersectionObserver | null = null
32
+ let motionQuery: MediaQueryList | null = null
33
+
34
+ /** 指针坐标 → 单位球空间(球心为原点,1 = 球半径),交给渲染器做视线跟随 */
35
+ const handlePointerMove = (e: PointerEvent) => {
36
+ const el = canvasRef.value
37
+ if (!el || !renderer) return
38
+ const rect = el.getBoundingClientRect()
39
+ const ballRadius = rect.width * 0.36
40
+ renderer.setPointer({
41
+ x: (e.clientX - rect.left - rect.width / 2) / ballRadius,
42
+ y: (e.clientY - rect.top - rect.height / 2) / ballRadius
43
+ })
44
+ }
45
+
46
+ const handlePointerLeave = () => renderer?.setPointer(null)
47
+
48
+ const handlePointerDown = () => renderer?.poke()
49
+
50
+ const applyVisibility = (visible: boolean) => {
51
+ if (!renderer) return
52
+ if (motionQuery?.matches) {
53
+ // 减少动态偏好:只画一帧静态画面,不启动画循环
54
+ renderer.stop()
55
+ renderer.renderOnce()
56
+ return
57
+ }
58
+ if (visible) renderer.start()
59
+ else renderer.stop()
60
+ }
61
+
62
+ const handleMotionChange = () => {
63
+ // 偏好像素级开关变化时重评估(不可见时 start 也会被 IntersectionObserver 纠正)
64
+ applyVisibility(true)
65
+ }
66
+
67
+ onMounted(() => {
68
+ const canvas = canvasRef.value
69
+ if (!canvas) return
70
+
71
+ renderer = createOrbRenderer(canvas, { size: props.size, status: props.status })
72
+
73
+ motionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null
74
+ motionQuery?.addEventListener?.('change', handleMotionChange)
75
+
76
+ if (typeof IntersectionObserver !== 'undefined') {
77
+ // 滚出视口的球暂停动画循环,长会话下同屏多个球也不累积开销
78
+ observer = new IntersectionObserver((entries) => {
79
+ applyVisibility(entries[0]?.isIntersecting ?? true)
80
+ })
81
+ observer.observe(canvas)
82
+ } else {
83
+ applyVisibility(true)
84
+ }
85
+ })
86
+
87
+ watch(
88
+ () => props.status,
89
+ (status) => renderer?.setStatus(status)
90
+ )
91
+
92
+ watch(
93
+ () => props.size,
94
+ (size) => renderer?.resize(size)
95
+ )
96
+
97
+ const react = (reaction: AiOrbReaction) => renderer?.trigger(reaction)
98
+
99
+ defineExpose<AiOrbExposed>({ react })
100
+
101
+ onBeforeUnmount(() => {
102
+ observer?.disconnect()
103
+ motionQuery?.removeEventListener?.('change', handleMotionChange)
104
+ renderer?.stop()
105
+ renderer = null
106
+ })
107
+ </script>
@@ -0,0 +1 @@
1
+ export { default as UAiOrb } from './ai-orb.vue'