@kine-design/ai-chat 0.0.1-beta.1 → 0.0.1-beta.3

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.
@@ -0,0 +1 @@
1
+ 6e6ccc4f-850a-43c4-b677-5af5ae931d77
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @description Socket.IO transport unit tests
3
+ * @author 阿怪
4
+ * @date 2026/5/7
5
+ * @version v0.0.1
6
+ *
7
+ * 江湖的业务千篇一律,复杂的代码好几百行。
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { createSocketIOTransport } from '../createSocketIOTransport';
12
+ import type { ServerPushEvent, TransportStatus } from '../types';
13
+
14
+ const listeners: Record<string, Function[]> = {};
15
+ const ioListeners: Record<string, Function[]> = {};
16
+
17
+ const mockSocket = {
18
+ on: vi.fn((event: string, handler: Function) => {
19
+ (listeners[event] ??= []).push(handler);
20
+ }),
21
+ emit: vi.fn(),
22
+ disconnect: vi.fn(),
23
+ io: {
24
+ on: vi.fn((event: string, handler: Function) => {
25
+ (ioListeners[event] ??= []).push(handler);
26
+ }),
27
+ },
28
+ };
29
+
30
+ vi.mock('socket.io-client', () => ({
31
+ io: vi.fn(() => mockSocket),
32
+ }));
33
+
34
+ function fireSocketEvent(event: string, ...args: unknown[]) {
35
+ listeners[event]?.forEach(h => h(...args));
36
+ }
37
+
38
+ function fireIOEvent(event: string, ...args: unknown[]) {
39
+ ioListeners[event]?.forEach(h => h(...args));
40
+ }
41
+
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ for (const key of Object.keys(listeners)) delete listeners[key];
45
+ for (const key of Object.keys(ioListeners)) delete ioListeners[key];
46
+ });
47
+
48
+ describe('createSocketIOTransport', () => {
49
+ it('connect emits connecting then connected on socket connect', () => {
50
+ const transport = createSocketIOTransport();
51
+ const statuses: TransportStatus[] = [];
52
+ transport.onStatusChange(s => statuses.push(s));
53
+
54
+ transport.connect('http://localhost:9506');
55
+ expect(statuses).toEqual(['connecting']);
56
+
57
+ fireSocketEvent('connect');
58
+ expect(statuses).toEqual(['connecting', 'connected']);
59
+ });
60
+
61
+ it('disconnect emits disconnected', () => {
62
+ const transport = createSocketIOTransport();
63
+ const statuses: TransportStatus[] = [];
64
+ transport.onStatusChange(s => statuses.push(s));
65
+
66
+ transport.connect('http://localhost:9506');
67
+ transport.disconnect();
68
+
69
+ expect(mockSocket.disconnect).toHaveBeenCalled();
70
+ expect(statuses[statuses.length - 1]).toBe('disconnected');
71
+ });
72
+
73
+ it('reconnect_attempt emits reconnecting', () => {
74
+ const transport = createSocketIOTransport();
75
+ const statuses: TransportStatus[] = [];
76
+ transport.onStatusChange(s => statuses.push(s));
77
+
78
+ transport.connect('http://localhost:9506');
79
+ fireIOEvent('reconnect_attempt');
80
+
81
+ expect(statuses).toContain('reconnecting');
82
+ });
83
+
84
+ it('send emits chat:send with OutgoingMessage', () => {
85
+ const transport = createSocketIOTransport();
86
+ transport.connect('http://localhost:9506');
87
+
88
+ const msg = { conversationId: 'conv-1', content: 'hello', timestamp: Date.now() };
89
+ transport.send(msg);
90
+
91
+ expect(mockSocket.emit).toHaveBeenCalledWith('chat:send', msg);
92
+ });
93
+
94
+ it('receives chat:event and forwards to handlers', () => {
95
+ const transport = createSocketIOTransport();
96
+ const events: ServerPushEvent[] = [];
97
+ transport.onEvent(e => events.push(e));
98
+
99
+ transport.connect('http://localhost:9506');
100
+
101
+ const pushEvent: ServerPushEvent = {
102
+ type: 'fragment_end',
103
+ conversationId: 'conv-1',
104
+ data: {
105
+ messageId: 'msg-1',
106
+ clusterId: 'ac-1',
107
+ role: 'assistant',
108
+ content: 'hello',
109
+ status: 'complete',
110
+ },
111
+ };
112
+ fireSocketEvent('chat:event', pushEvent);
113
+
114
+ expect(events).toEqual([pushEvent]);
115
+ });
116
+
117
+ it('receives phase_change events', () => {
118
+ const transport = createSocketIOTransport();
119
+ const events: ServerPushEvent[] = [];
120
+ transport.onEvent(e => events.push(e));
121
+
122
+ transport.connect('http://localhost:9506');
123
+
124
+ const pushEvent: ServerPushEvent = {
125
+ type: 'phase_change',
126
+ conversationId: 'conv-1',
127
+ data: { phase: 'thinking' },
128
+ };
129
+ fireSocketEvent('chat:event', pushEvent);
130
+
131
+ expect(events).toHaveLength(1);
132
+ expect(events[0].type).toBe('phase_change');
133
+ });
134
+
135
+ it('passes auth and headers to socket.io options', async () => {
136
+ const { io } = await import('socket.io-client');
137
+ const transport = createSocketIOTransport();
138
+
139
+ transport.connect('http://localhost:9506', {
140
+ auth: { token: 'my-token' },
141
+ headers: { 'X-Custom': 'value' },
142
+ reconnect: true,
143
+ maxReconnectAttempts: 3,
144
+ reconnectInterval: 1000,
145
+ });
146
+
147
+ expect(io).toHaveBeenCalledWith('http://localhost:9506', {
148
+ auth: { token: 'my-token' },
149
+ extraHeaders: { 'X-Custom': 'value' },
150
+ reconnection: true,
151
+ reconnectionAttempts: 3,
152
+ reconnectionDelay: 1000,
153
+ });
154
+ });
155
+
156
+ it('multiple event handlers all receive events', () => {
157
+ const transport = createSocketIOTransport();
158
+ const events1: ServerPushEvent[] = [];
159
+ const events2: ServerPushEvent[] = [];
160
+ transport.onEvent(e => events1.push(e));
161
+ transport.onEvent(e => events2.push(e));
162
+
163
+ transport.connect('http://localhost:9506');
164
+
165
+ const pushEvent: ServerPushEvent = {
166
+ type: 'fragment_end',
167
+ conversationId: 'conv-1',
168
+ data: {
169
+ messageId: 'msg-1',
170
+ clusterId: 'ac-1',
171
+ role: 'assistant',
172
+ content: 'test',
173
+ status: 'complete',
174
+ },
175
+ };
176
+ fireSocketEvent('chat:event', pushEvent);
177
+
178
+ expect(events1).toHaveLength(1);
179
+ expect(events2).toHaveLength(1);
180
+ });
181
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @description WebSocket transport unit tests
3
+ * @author 阿怪
4
+ * @date 2026/5/7
5
+ * @version v0.0.1
6
+ *
7
+ * 江湖的业务千篇一律,复杂的代码好几百行。
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { createWebSocketTransport } from '../createWebSocketTransport';
12
+ import type { ServerPushEvent, TransportStatus } from '../types';
13
+
14
+ let mockWs: {
15
+ onopen: (() => void) | null;
16
+ onclose: (() => void) | null;
17
+ onerror: (() => void) | null;
18
+ onmessage: ((e: { data: string }) => void) | null;
19
+ send: ReturnType<typeof vi.fn>;
20
+ close: ReturnType<typeof vi.fn>;
21
+ readyState: number;
22
+ };
23
+
24
+ beforeEach(() => {
25
+ mockWs = {
26
+ onopen: null,
27
+ onclose: null,
28
+ onerror: null,
29
+ onmessage: null,
30
+ send: vi.fn(),
31
+ close: vi.fn(),
32
+ readyState: 1,
33
+ };
34
+
35
+ const MockWebSocket = function () { return mockWs; } as unknown as typeof WebSocket;
36
+ Object.defineProperty(MockWebSocket, 'OPEN', { value: 1 });
37
+ vi.stubGlobal('WebSocket', MockWebSocket);
38
+ });
39
+
40
+ describe('createWebSocketTransport', () => {
41
+ it('connect emits connecting then connected on ws open', () => {
42
+ const transport = createWebSocketTransport();
43
+ const statuses: TransportStatus[] = [];
44
+ transport.onStatusChange(s => statuses.push(s));
45
+
46
+ transport.connect('ws://localhost:9506/chat');
47
+ expect(statuses).toEqual(['connecting']);
48
+
49
+ mockWs.onopen?.();
50
+ expect(statuses).toEqual(['connecting', 'connected']);
51
+ });
52
+
53
+ it('disconnect closes ws and emits disconnected', () => {
54
+ const transport = createWebSocketTransport();
55
+ const statuses: TransportStatus[] = [];
56
+ transport.onStatusChange(s => statuses.push(s));
57
+
58
+ transport.connect('ws://localhost:9506/chat');
59
+ transport.disconnect();
60
+
61
+ expect(mockWs.close).toHaveBeenCalled();
62
+ expect(statuses[statuses.length - 1]).toBe('disconnected');
63
+ });
64
+
65
+ it('send serializes OutgoingMessage as JSON', () => {
66
+ const transport = createWebSocketTransport();
67
+ transport.connect('ws://localhost:9506/chat');
68
+ mockWs.onopen?.();
69
+
70
+ const msg = { conversationId: 'conv-1', content: 'hello', timestamp: 1000 };
71
+ transport.send(msg);
72
+
73
+ expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(msg));
74
+ });
75
+
76
+ it('receives messages and forwards parsed ServerPushEvent to handlers', () => {
77
+ const transport = createWebSocketTransport();
78
+ const events: ServerPushEvent[] = [];
79
+ transport.onEvent(e => events.push(e));
80
+
81
+ transport.connect('ws://localhost:9506/chat');
82
+ mockWs.onopen?.();
83
+
84
+ const pushEvent: ServerPushEvent = {
85
+ type: 'fragment_end',
86
+ conversationId: 'conv-1',
87
+ data: {
88
+ messageId: 'msg-1',
89
+ clusterId: 'ac-1',
90
+ role: 'assistant',
91
+ content: 'hello',
92
+ status: 'complete',
93
+ },
94
+ };
95
+ mockWs.onmessage?.({ data: JSON.stringify(pushEvent) });
96
+
97
+ expect(events).toEqual([pushEvent]);
98
+ });
99
+
100
+ it('ignores non-JSON messages', () => {
101
+ const transport = createWebSocketTransport();
102
+ const events: ServerPushEvent[] = [];
103
+ transport.onEvent(e => events.push(e));
104
+
105
+ transport.connect('ws://localhost:9506/chat');
106
+ mockWs.onopen?.();
107
+ mockWs.onmessage?.({ data: 'not json' });
108
+
109
+ expect(events).toHaveLength(0);
110
+ });
111
+
112
+ it('reconnects on close when reconnect is enabled', () => {
113
+ vi.useFakeTimers();
114
+ const transport = createWebSocketTransport();
115
+ const statuses: TransportStatus[] = [];
116
+ transport.onStatusChange(s => statuses.push(s));
117
+
118
+ transport.connect('ws://localhost:9506/chat', {
119
+ reconnect: true,
120
+ reconnectInterval: 1000,
121
+ maxReconnectAttempts: 3,
122
+ });
123
+ mockWs.onopen?.();
124
+ mockWs.onclose?.();
125
+
126
+ expect(statuses).toContain('reconnecting');
127
+
128
+ vi.advanceTimersByTime(1000);
129
+ // reconnect creates a new WebSocket — verify by checking status went to reconnecting
130
+ expect(statuses).toContain('reconnecting');
131
+
132
+ vi.useRealTimers();
133
+ });
134
+
135
+ it('does not reconnect when reconnect is disabled', () => {
136
+ const transport = createWebSocketTransport();
137
+ const statuses: TransportStatus[] = [];
138
+ transport.onStatusChange(s => statuses.push(s));
139
+
140
+ transport.connect('ws://localhost:9506/chat', { reconnect: false });
141
+ mockWs.onclose?.();
142
+
143
+ expect(statuses).not.toContain('reconnecting');
144
+ });
145
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @description Socket.IO transport — speaks ServerPushEvent protocol over Socket.IO
3
+ * @author 阿怪
4
+ * @date 2026/5/7
5
+ * @version v0.0.1
6
+ *
7
+ * 江湖的业务千篇一律,复杂的代码好几百行。
8
+ */
9
+
10
+ import { io, type Socket } from 'socket.io-client';
11
+ import type {
12
+ ChatTransport,
13
+ OutgoingMessage,
14
+ ServerPushEvent,
15
+ TransportOptions,
16
+ TransportStatus,
17
+ } from './types';
18
+
19
+ export function createSocketIOTransport(): ChatTransport {
20
+ let socket: Socket | null = null;
21
+
22
+ const eventHandlers: Array<(event: ServerPushEvent) => void> = [];
23
+ const statusHandlers: Array<(status: TransportStatus) => void> = [];
24
+
25
+ function emitStatus(status: TransportStatus) {
26
+ statusHandlers.forEach(h => h(status));
27
+ }
28
+
29
+ function connect(url: string, options: TransportOptions = {}) {
30
+ emitStatus('connecting');
31
+
32
+ socket = io(url, {
33
+ auth: options.auth,
34
+ extraHeaders: options.headers,
35
+ reconnection: options.reconnect ?? true,
36
+ reconnectionAttempts: options.maxReconnectAttempts ?? 5,
37
+ reconnectionDelay: options.reconnectInterval ?? 3000,
38
+ });
39
+
40
+ socket.on('connect', () => {
41
+ emitStatus('connected');
42
+ });
43
+
44
+ socket.on('disconnect', () => {
45
+ emitStatus('disconnected');
46
+ });
47
+
48
+ socket.io.on('reconnect_attempt', () => {
49
+ emitStatus('reconnecting');
50
+ });
51
+
52
+ socket.on('chat:event', (event: ServerPushEvent) => {
53
+ eventHandlers.forEach(h => h(event));
54
+ });
55
+ }
56
+
57
+ function disconnect() {
58
+ socket?.disconnect();
59
+ socket = null;
60
+ emitStatus('disconnected');
61
+ }
62
+
63
+ function send(message: OutgoingMessage) {
64
+ socket?.emit('chat:send', message);
65
+ }
66
+
67
+ function onEvent(handler: (event: ServerPushEvent) => void) {
68
+ eventHandlers.push(handler);
69
+ }
70
+
71
+ function onStatusChange(handler: (status: TransportStatus) => void) {
72
+ statusHandlers.push(handler);
73
+ }
74
+
75
+ return { connect, disconnect, send, onEvent, onStatusChange };
76
+ }
@@ -77,6 +77,7 @@ export type TransportStatus = 'connecting' | 'connected' | 'disconnected' | 'rec
77
77
  export interface TransportOptions {
78
78
  protocols?: string[];
79
79
  headers?: Record<string, string>;
80
+ auth?: Record<string, string>;
80
81
  reconnect?: boolean;
81
82
  reconnectInterval?: number;
82
83
  maxReconnectAttempts?: number;
package/dist/ai-chat.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { computed, createTextVNode, createVNode, defineComponent, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
2
+ import { io } from "socket.io-client";
2
3
  import KLoading from "kine-ui/components/loading/KLoading.tsx";
3
4
  //#region composables/useChat.ts
4
5
  /**
@@ -254,6 +255,67 @@ function createWebSocketTransport() {
254
255
  };
255
256
  }
256
257
  //#endregion
258
+ //#region composables/createSocketIOTransport.ts
259
+ /**
260
+ * @description Socket.IO transport — speaks ServerPushEvent protocol over Socket.IO
261
+ * @author 阿怪
262
+ * @date 2026/5/7
263
+ * @version v0.0.1
264
+ *
265
+ * 江湖的业务千篇一律,复杂的代码好几百行。
266
+ */
267
+ function createSocketIOTransport() {
268
+ let socket = null;
269
+ const eventHandlers = [];
270
+ const statusHandlers = [];
271
+ function emitStatus(status) {
272
+ statusHandlers.forEach((h) => h(status));
273
+ }
274
+ function connect(url, options = {}) {
275
+ emitStatus("connecting");
276
+ socket = io(url, {
277
+ auth: options.auth,
278
+ extraHeaders: options.headers,
279
+ reconnection: options.reconnect ?? true,
280
+ reconnectionAttempts: options.maxReconnectAttempts ?? 5,
281
+ reconnectionDelay: options.reconnectInterval ?? 3e3
282
+ });
283
+ socket.on("connect", () => {
284
+ emitStatus("connected");
285
+ });
286
+ socket.on("disconnect", () => {
287
+ emitStatus("disconnected");
288
+ });
289
+ socket.io.on("reconnect_attempt", () => {
290
+ emitStatus("reconnecting");
291
+ });
292
+ socket.on("chat:event", (event) => {
293
+ eventHandlers.forEach((h) => h(event));
294
+ });
295
+ }
296
+ function disconnect() {
297
+ socket?.disconnect();
298
+ socket = null;
299
+ emitStatus("disconnected");
300
+ }
301
+ function send(message) {
302
+ socket?.emit("chat:send", message);
303
+ }
304
+ function onEvent(handler) {
305
+ eventHandlers.push(handler);
306
+ }
307
+ function onStatusChange(handler) {
308
+ statusHandlers.push(handler);
309
+ }
310
+ return {
311
+ connect,
312
+ disconnect,
313
+ send,
314
+ onEvent,
315
+ onStatusChange
316
+ };
317
+ }
318
+ //#endregion
257
319
  //#region components/KMessageBubble.tsx
258
320
  /**
259
321
  * @description Single message bubble — supports streaming content
@@ -433,4 +495,4 @@ var KChatInput = /* @__PURE__ */ defineComponent({
433
495
  }
434
496
  });
435
497
  //#endregion
436
- export { KChatInput, KChatPanel, KMessageBubble, KMessageCluster, KPhaseIndicator, createWebSocketTransport, useChat, useChatHistory };
498
+ export { KChatInput, KChatPanel, KMessageBubble, KMessageCluster, KPhaseIndicator, createSocketIOTransport, createWebSocketTransport, useChat, useChatHistory };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @description Socket.IO transport unit tests
3
+ * @author 阿怪
4
+ * @date 2026/5/7
5
+ * @version v0.0.1
6
+ *
7
+ * 江湖的业务千篇一律,复杂的代码好几百行。
8
+ */
9
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @description WebSocket transport unit tests
3
+ * @author 阿怪
4
+ * @date 2026/5/7
5
+ * @version v0.0.1
6
+ *
7
+ * 江湖的业务千篇一律,复杂的代码好几百行。
8
+ */
9
+ export {};
@@ -0,0 +1,2 @@
1
+ import { ChatTransport } from './types';
2
+ export declare function createSocketIOTransport(): ChatTransport;
@@ -59,6 +59,7 @@ export type TransportStatus = 'connecting' | 'connected' | 'disconnected' | 'rec
59
59
  export interface TransportOptions {
60
60
  protocols?: string[];
61
61
  headers?: Record<string, string>;
62
+ auth?: Record<string, string>;
62
63
  reconnect?: boolean;
63
64
  reconnectInterval?: number;
64
65
  maxReconnectAttempts?: number;
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  export { useChat } from './composables/useChat';
10
10
  export { useChatHistory } from './composables/useChatHistory';
11
11
  export { createWebSocketTransport } from './composables/createWebSocketTransport';
12
+ export { createSocketIOTransport } from './composables/createSocketIOTransport';
12
13
  export type { MessageRole, MessageStatus, ChatMessage, MessageCluster, ChatPhase, Conversation, ServerPushEvent, FragmentEvent, PhaseChangeEvent, ConversationMetaEvent, ChatTransport, TransportStatus, TransportOptions, OutgoingMessage, UseChatOptions, UseChatReturn, UseChatHistoryOptions, UseChatHistoryReturn, } from './composables/types';
13
14
  export { KChatPanel } from './components/KChatPanel';
14
15
  export { KMessageCluster } from './components/KMessageCluster';
@@ -0,0 +1,2 @@
1
+ declare const _default: import('vite').UserConfig;
2
+ export default _default;
package/index.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  export { useChat } from './composables/useChat';
11
11
  export { useChatHistory } from './composables/useChatHistory';
12
12
  export { createWebSocketTransport } from './composables/createWebSocketTransport';
13
+ export { createSocketIOTransport } from './composables/createSocketIOTransport';
13
14
 
14
15
  export type {
15
16
  MessageRole,
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@kine-design/ai-chat",
3
- "version": "0.0.1-beta.1",
3
+ "version": "0.0.1-beta.3",
4
4
  "type": "module",
5
5
  "main": "./dist/ai-chat.js",
6
6
  "types": "./dist/index.d.ts",
7
7
  "dependencies": {
8
+ "socket.io-client": "^4.8.3",
8
9
  "vue": "^3.5.30",
9
- "kine-ui": "0.0.1-beta.16"
10
+ "kine-ui": "^0.0.1-beta.18"
10
11
  },
11
12
  "publishConfig": {
12
13
  "access": "public",
@@ -14,8 +15,12 @@
14
15
  "dist"
15
16
  ]
16
17
  },
18
+ "devDependencies": {
19
+ "vitest": "^4.1.0"
20
+ },
17
21
  "scripts": {
18
- "build": "vite build --config vite.config.build.ts"
22
+ "build": "vite build --config vite.config.build.ts",
23
+ "test": "vitest --run"
19
24
  },
20
25
  "module": "./dist/ai-chat.js",
21
26
  "exports": {
@@ -18,6 +18,7 @@ export default defineConfig({
18
18
  external: [
19
19
  'vue',
20
20
  /^kine-ui/,
21
+ /^socket\.io-client/,
21
22
  ],
22
23
  output: {
23
24
  assetFileNames: 'ai-chat.[ext]',
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import vueJsx from '@vitejs/plugin-vue-jsx';
3
+
4
+ export default defineConfig({
5
+ root: __dirname,
6
+ plugins: [vueJsx()],
7
+ test: {
8
+ include: ['**/__tests__/*.test.ts'],
9
+ },
10
+ });