@veltra/ai 1.4.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.
- package/dist/chat/index.d.ts +3 -3
- package/dist/chat/transports/index.d.ts +2 -2
- package/dist/chat/transports/openai.d.ts +16 -12
- package/dist/chat/transports/openai.js +56 -15
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/types.d.ts +41 -2
- package/dist/chat/use-chat.d.ts +26 -1
- package/dist/chat/use-chat.js +132 -13
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +222 -41
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/ask-question.js +2 -2
- package/dist/components/ai-chat/ask-question.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +81 -44
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/di.js.map +1 -1
- package/dist/components/ai-chat/message-item.js +101 -23
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js +217 -32
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/model-picker.js +140 -0
- package/dist/components/ai-chat/model-picker.js.map +1 -0
- package/dist/components/ai-chat/queue-list.js +70 -0
- package/dist/components/ai-chat/queue-list.js.map +1 -0
- package/dist/components/ai-chat/side-panel.js +96 -0
- package/dist/components/ai-chat/side-panel.js.map +1 -0
- package/dist/components/ai-chat/style.d.ts +6 -0
- package/dist/components/ai-chat/style.js +6 -0
- package/dist/components/ai-chat/style2.css +552 -41
- package/dist/components/ai-chat/tool-call.js +61 -27
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-orb/ai-orb.js +94 -0
- package/dist/components/ai-orb/ai-orb.js.map +1 -0
- package/dist/components/ai-orb/orb-renderer.js +411 -0
- package/dist/components/ai-orb/orb-renderer.js.map +1 -0
- package/dist/components/ai-orb/style.d.ts +1 -0
- package/dist/components/ai-orb/style.js +1 -0
- package/dist/components/ai-orb/style2.css +23 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.js +2 -2
- package/dist/providers/types.d.ts +47 -0
- package/dist/style.js +1 -0
- package/dist/tools/ask-question/ask-question.d.ts +2 -17
- package/dist/tools/ask-question/index.d.ts +2 -2
- package/dist/tools/index.d.ts +3 -2
- package/dist/tools/index.js +10 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/types/ai-chat.d.ts +35 -7
- package/dist/types/ai-orb.d.ts +27 -0
- package/dist/types/index.d.ts +2 -1
- package/package.json +8 -8
- package/src/chat/__test__/use-chat.test.ts +295 -9
- package/src/chat/transports/__test__/openai.test.ts +317 -0
- package/src/chat/transports/openai.ts +88 -26
- package/src/chat/types.ts +41 -1
- package/src/chat/use-chat.ts +177 -14
- package/src/components/ai-chat/__test__/ai-chat.test.ts +690 -12
- package/src/components/ai-chat/ai-chat.vue +241 -31
- package/src/components/ai-chat/chat-input.vue +78 -31
- package/src/components/ai-chat/di.ts +2 -0
- package/src/components/ai-chat/message-item.vue +98 -11
- package/src/components/ai-chat/message-list.vue +261 -21
- package/src/components/ai-chat/model-picker.vue +177 -0
- package/src/components/ai-chat/queue-list.vue +63 -0
- package/src/components/ai-chat/side-panel.vue +76 -0
- package/src/components/ai-chat/style.scss +621 -43
- package/src/components/ai-chat/style.ts +6 -0
- package/src/components/ai-chat/tool-call.vue +86 -42
- package/src/components/ai-orb/__test__/ai-orb.test.ts +251 -0
- package/src/components/ai-orb/ai-orb.vue +107 -0
- package/src/components/ai-orb/index.ts +1 -0
- package/src/components/ai-orb/orb-renderer.ts +505 -0
- package/src/components/ai-orb/style.scss +10 -0
- package/src/components/ai-orb/style.ts +1 -0
- package/src/components/index.ts +1 -0
- package/src/index.ts +8 -1
- package/src/providers/index.ts +1 -0
- package/src/providers/types.ts +46 -0
- package/src/style.ts +1 -0
- package/src/tools/ask-question/__test__/ask-question.test.ts +4 -4
- package/src/tools/index.ts +14 -1
- package/src/types/ai-chat.ts +35 -6
- package/src/types/ai-orb.ts +27 -0
- package/src/types/index.ts +1 -0
- package/dist/components/ai-chat/ai-chat.vue.d.ts +0 -36
- package/dist/components/ai-chat/index.d.ts +0 -2
- package/dist/components/index.d.ts +0 -3
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { ChatTransportHandlers } from '../../types'
|
|
4
|
+
import { createOpenAITransport } from '../openai'
|
|
5
|
+
|
|
6
|
+
const baseHandlers = (): ChatTransportHandlers => ({
|
|
7
|
+
onTextDelta: vi.fn(),
|
|
8
|
+
onReasoningDelta: vi.fn(),
|
|
9
|
+
onToolCall: vi.fn(),
|
|
10
|
+
onError: vi.fn()
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.unstubAllGlobals()
|
|
15
|
+
vi.restoreAllMocks()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('createOpenAITransport', () => {
|
|
19
|
+
it('providers 为空时抛错', () => {
|
|
20
|
+
expect(() => createOpenAITransport({ providers: [] })).toThrow(/providers 不能为空/)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('跨 Provider 模型 id 重复时抛错', () => {
|
|
24
|
+
expect(() =>
|
|
25
|
+
createOpenAITransport({
|
|
26
|
+
providers: [
|
|
27
|
+
{
|
|
28
|
+
id: 'a',
|
|
29
|
+
endpoint: 'https://a.example/v1/chat/completions',
|
|
30
|
+
models: [{ id: 'shared' }]
|
|
31
|
+
},
|
|
32
|
+
{ id: 'b', endpoint: 'https://b.example/v1/chat/completions', models: [{ id: 'shared' }] }
|
|
33
|
+
]
|
|
34
|
+
})
|
|
35
|
+
).toThrow(/模型 id "shared" 重复/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('挂载 models 与 defaultModel', () => {
|
|
39
|
+
const transport = createOpenAITransport({
|
|
40
|
+
providers: [
|
|
41
|
+
{
|
|
42
|
+
id: 'openai',
|
|
43
|
+
label: 'OpenAI',
|
|
44
|
+
endpoint: 'https://api.openai.com/v1/chat/completions',
|
|
45
|
+
apiKey: 'sk-test',
|
|
46
|
+
models: [
|
|
47
|
+
{ id: 'gpt-4o', label: 'GPT-4o' },
|
|
48
|
+
{ id: 'o3-mini', label: 'o3-mini' }
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(transport.defaultModel).toBe('gpt-4o')
|
|
55
|
+
expect(transport.models).toEqual([
|
|
56
|
+
{ id: 'gpt-4o', label: 'GPT-4o', providerId: 'openai', providerLabel: 'OpenAI' },
|
|
57
|
+
{ id: 'o3-mini', label: 'o3-mini', providerId: 'openai', providerLabel: 'OpenAI' }
|
|
58
|
+
])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('按 model 路由到不同 endpoint,相对路径原样 fetch', async () => {
|
|
62
|
+
const fetchMock = vi.fn(async () => {
|
|
63
|
+
return new Response('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n', {
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
69
|
+
|
|
70
|
+
const transport = createOpenAITransport({
|
|
71
|
+
providers: [
|
|
72
|
+
{ id: 'proxy', endpoint: '/api/ai/chat', models: [{ id: 'proxy-model' }] },
|
|
73
|
+
{
|
|
74
|
+
id: 'remote',
|
|
75
|
+
endpoint: 'https://api.example.com/v1/chat/completions',
|
|
76
|
+
apiKey: 'sk-remote',
|
|
77
|
+
models: [{ id: 'remote-model' }]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const handlers = baseHandlers()
|
|
83
|
+
await transport(
|
|
84
|
+
{
|
|
85
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
86
|
+
model: 'remote-model',
|
|
87
|
+
signal: new AbortController().signal
|
|
88
|
+
},
|
|
89
|
+
handlers
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
93
|
+
expect(fetchMock.mock.calls[0]![0]).toBe('https://api.example.com/v1/chat/completions')
|
|
94
|
+
const remoteInit = fetchMock.mock.calls[0]![1] as RequestInit
|
|
95
|
+
expect(remoteInit.headers).toMatchObject({ Authorization: 'Bearer sk-remote' })
|
|
96
|
+
expect(JSON.parse(String(remoteInit.body))).toMatchObject({
|
|
97
|
+
model: 'remote-model',
|
|
98
|
+
stream: true
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await transport(
|
|
102
|
+
{
|
|
103
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
104
|
+
model: 'proxy-model',
|
|
105
|
+
signal: new AbortController().signal
|
|
106
|
+
},
|
|
107
|
+
handlers
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(fetchMock.mock.calls[1]![0]).toBe('/api/ai/chat')
|
|
111
|
+
const proxyInit = fetchMock.mock.calls[1]![1] as RequestInit
|
|
112
|
+
expect((proxyInit.headers as Record<string, string>).Authorization).toBeUndefined()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('无 apiKey 时不带 Authorization', async () => {
|
|
116
|
+
const fetchMock = vi.fn(async () => {
|
|
117
|
+
return new Response('data: [DONE]\n\n', {
|
|
118
|
+
status: 200,
|
|
119
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
123
|
+
|
|
124
|
+
const transport = createOpenAITransport({
|
|
125
|
+
providers: [{ id: 'local', endpoint: '/api/chat', models: [{ id: 'local-model' }] }]
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
await transport(
|
|
129
|
+
{
|
|
130
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
131
|
+
signal: new AbortController().signal
|
|
132
|
+
},
|
|
133
|
+
baseHandlers()
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>
|
|
137
|
+
expect(headers.Authorization).toBeUndefined()
|
|
138
|
+
expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
|
|
139
|
+
model: 'local-model'
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('缺省将 reasoningLevel 写入 reasoning_effort', async () => {
|
|
144
|
+
const fetchMock = vi.fn(async () => {
|
|
145
|
+
return new Response('data: [DONE]\n\n', {
|
|
146
|
+
status: 200,
|
|
147
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
151
|
+
|
|
152
|
+
const transport = createOpenAITransport({
|
|
153
|
+
providers: [
|
|
154
|
+
{
|
|
155
|
+
id: 'openai',
|
|
156
|
+
endpoint: 'https://api.openai.com/v1/chat/completions',
|
|
157
|
+
apiKey: 'sk',
|
|
158
|
+
models: [{ id: 'o3-mini' }]
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
await transport(
|
|
164
|
+
{
|
|
165
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
166
|
+
model: 'o3-mini',
|
|
167
|
+
reasoningLevel: 'high',
|
|
168
|
+
signal: new AbortController().signal
|
|
169
|
+
},
|
|
170
|
+
baseHandlers()
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
|
|
174
|
+
model: 'o3-mini',
|
|
175
|
+
reasoning_effort: 'high'
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('applyReasoning 可自定义写入字段', async () => {
|
|
180
|
+
const fetchMock = vi.fn(async () => {
|
|
181
|
+
return new Response('data: [DONE]\n\n', {
|
|
182
|
+
status: 200,
|
|
183
|
+
headers: { 'Content-Type': 'text/event-stream' }
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
187
|
+
|
|
188
|
+
const transport = createOpenAITransport({
|
|
189
|
+
providers: [
|
|
190
|
+
{
|
|
191
|
+
id: 'custom',
|
|
192
|
+
endpoint: 'https://custom.example/chat',
|
|
193
|
+
applyReasoning: (level, body) => {
|
|
194
|
+
body.thinking = { budget: level }
|
|
195
|
+
},
|
|
196
|
+
models: [{ id: 'custom-model' }]
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
await transport(
|
|
202
|
+
{
|
|
203
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
204
|
+
reasoningLevel: '8k',
|
|
205
|
+
signal: new AbortController().signal
|
|
206
|
+
},
|
|
207
|
+
baseHandlers()
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
|
|
211
|
+
thinking: { budget: '8k' }
|
|
212
|
+
})
|
|
213
|
+
expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body)).reasoning_effort).toBeUndefined()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('未知 model 时通过 onError 报错且不发请求', async () => {
|
|
217
|
+
const fetchMock = vi.fn()
|
|
218
|
+
vi.stubGlobal('fetch', fetchMock)
|
|
219
|
+
const handlers = baseHandlers()
|
|
220
|
+
|
|
221
|
+
const transport = createOpenAITransport({
|
|
222
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'known' }] }]
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
await transport(
|
|
226
|
+
{
|
|
227
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
228
|
+
model: 'unknown',
|
|
229
|
+
signal: new AbortController().signal
|
|
230
|
+
},
|
|
231
|
+
handlers
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
expect(fetchMock).not.toHaveBeenCalled()
|
|
235
|
+
expect(handlers.onError).toHaveBeenCalledWith(
|
|
236
|
+
expect.objectContaining({ message: expect.stringContaining('unknown') })
|
|
237
|
+
)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('工具调用参数分片跨 chunk 累积为一次完整 onToolCall', async () => {
|
|
241
|
+
// 同一工具调用的多个分片共享 index:0,不得被误判为"新一轮"而提前 flush
|
|
242
|
+
const sse = String.raw`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"calculate","arguments":""}}]}}]}
|
|
243
|
+
|
|
244
|
+
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"ex"}}]}}]}
|
|
245
|
+
|
|
246
|
+
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"pression\":\"1+1\"}"}}]}}]}
|
|
247
|
+
|
|
248
|
+
data: [DONE]
|
|
249
|
+
`
|
|
250
|
+
vi.stubGlobal(
|
|
251
|
+
'fetch',
|
|
252
|
+
vi.fn(async () => new Response(sse, { status: 200 }))
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
const transport = createOpenAITransport({
|
|
256
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
257
|
+
})
|
|
258
|
+
const handlers = baseHandlers()
|
|
259
|
+
|
|
260
|
+
await transport(
|
|
261
|
+
{
|
|
262
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
263
|
+
signal: new AbortController().signal
|
|
264
|
+
},
|
|
265
|
+
handlers
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
expect(handlers.onToolCall).toHaveBeenCalledTimes(1)
|
|
269
|
+
expect(handlers.onToolCall).toHaveBeenCalledWith({
|
|
270
|
+
id: 'call_1',
|
|
271
|
+
name: 'calculate',
|
|
272
|
+
arguments: '{"expression":"1+1"}'
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('多个工具调用按 index 顺序完整抛出', async () => {
|
|
277
|
+
const sse = String.raw`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"alpha","arguments":""}}]}}]}
|
|
278
|
+
|
|
279
|
+
data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"x\":1}"}}]}}]}
|
|
280
|
+
|
|
281
|
+
data: {"choices":[{"delta":{"tool_calls":[{"index":1,"id":"call_2","function":{"name":"beta","arguments":""}}]}}]}
|
|
282
|
+
|
|
283
|
+
data: {"choices":[{"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\"y\":2}"}}]}}]}
|
|
284
|
+
|
|
285
|
+
data: [DONE]
|
|
286
|
+
`
|
|
287
|
+
vi.stubGlobal(
|
|
288
|
+
'fetch',
|
|
289
|
+
vi.fn(async () => new Response(sse, { status: 200 }))
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
const transport = createOpenAITransport({
|
|
293
|
+
providers: [{ id: 'a', endpoint: 'https://a.example/chat', models: [{ id: 'm' }] }]
|
|
294
|
+
})
|
|
295
|
+
const handlers = baseHandlers()
|
|
296
|
+
|
|
297
|
+
await transport(
|
|
298
|
+
{
|
|
299
|
+
messages: [{ id: '1', role: 'user', content: 'hi' }],
|
|
300
|
+
signal: new AbortController().signal
|
|
301
|
+
},
|
|
302
|
+
handlers
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
expect(handlers.onToolCall).toHaveBeenCalledTimes(2)
|
|
306
|
+
expect(handlers.onToolCall).toHaveBeenNthCalledWith(1, {
|
|
307
|
+
id: 'call_1',
|
|
308
|
+
name: 'alpha',
|
|
309
|
+
arguments: '{"x":1}'
|
|
310
|
+
})
|
|
311
|
+
expect(handlers.onToolCall).toHaveBeenNthCalledWith(2, {
|
|
312
|
+
id: 'call_2',
|
|
313
|
+
name: 'beta',
|
|
314
|
+
arguments: '{"y":2}'
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ChatModelOption, ChatProvider } from '../../providers'
|
|
1
2
|
import type {
|
|
2
3
|
ChatMessage,
|
|
3
4
|
ChatTool,
|
|
@@ -6,20 +7,24 @@ import type {
|
|
|
6
7
|
ChatTransportRequest
|
|
7
8
|
} from '../types'
|
|
8
9
|
|
|
9
|
-
/** createOpenAITransport
|
|
10
|
+
/** createOpenAITransport 的配置项(多 Provider) */
|
|
10
11
|
export interface OpenAITransportOptions {
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
apiKey?: string
|
|
15
|
-
/** 模型名 */
|
|
16
|
-
model: string
|
|
17
|
-
/** 额外请求头 */
|
|
12
|
+
/** 至少一个 Provider;模型 id 须跨 Provider 全局唯一 */
|
|
13
|
+
providers: ChatProvider[]
|
|
14
|
+
/** 全局额外请求头(与各 Provider headers 合并,Provider 优先) */
|
|
18
15
|
headers?: Record<string, string>
|
|
19
|
-
/**
|
|
16
|
+
/** 全局额外请求体字段,如 temperature、top_p */
|
|
20
17
|
body?: Record<string, unknown>
|
|
21
18
|
}
|
|
22
19
|
|
|
20
|
+
/** 带扁平模型列表元数据的 OpenAI 兼容 transport */
|
|
21
|
+
export type OpenAITransport = ChatTransport & {
|
|
22
|
+
/** 供 UI 使用的扁平模型列表 */
|
|
23
|
+
readonly models: ChatModelOption[]
|
|
24
|
+
/** 默认模型 id(首个 Provider 的首个模型) */
|
|
25
|
+
readonly defaultModel: string
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
interface OpenAIMessage {
|
|
24
29
|
role: string
|
|
25
30
|
content?: unknown
|
|
@@ -130,8 +135,8 @@ async function parseSSE(
|
|
|
130
135
|
if (Array.isArray(delta.tool_calls)) {
|
|
131
136
|
for (const tc of delta.tool_calls) {
|
|
132
137
|
const index: number = tc.index ?? 0
|
|
133
|
-
// index
|
|
134
|
-
if (index
|
|
138
|
+
// 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去
|
|
139
|
+
if (index > lastIndex && accumulated.size > 0) flushToolCalls()
|
|
135
140
|
lastIndex = Math.max(lastIndex, index)
|
|
136
141
|
|
|
137
142
|
let call = accumulated.get(index)
|
|
@@ -178,33 +183,88 @@ async function parseSSE(
|
|
|
178
183
|
flushToolCalls()
|
|
179
184
|
}
|
|
180
185
|
|
|
186
|
+
/** 校验 providers 并展开扁平模型列表 */
|
|
187
|
+
function flattenModels(providers: ChatProvider[]): {
|
|
188
|
+
models: ChatModelOption[]
|
|
189
|
+
byModelId: Map<string, ChatProvider>
|
|
190
|
+
} {
|
|
191
|
+
if (!providers.length) {
|
|
192
|
+
throw new Error('[createOpenAITransport] providers 不能为空')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const models: ChatModelOption[] = []
|
|
196
|
+
const byModelId = new Map<string, ChatProvider>()
|
|
197
|
+
|
|
198
|
+
for (const provider of providers) {
|
|
199
|
+
if (!provider.models?.length) {
|
|
200
|
+
throw new Error(`[createOpenAITransport] Provider "${provider.id}" 未配置 models`)
|
|
201
|
+
}
|
|
202
|
+
for (const model of provider.models) {
|
|
203
|
+
if (byModelId.has(model.id)) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`[createOpenAITransport] 模型 id "${model.id}" 重复(跨 Provider 须全局唯一)`
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
byModelId.set(model.id, provider)
|
|
209
|
+
models.push({ ...model, providerId: provider.id, providerLabel: provider.label })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { models, byModelId }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 缺省:写入 OpenAI 兼容的 reasoning_effort */
|
|
217
|
+
function defaultApplyReasoning(level: string, body: Record<string, unknown>) {
|
|
218
|
+
body.reasoning_effort = level
|
|
219
|
+
}
|
|
220
|
+
|
|
181
221
|
/**
|
|
182
222
|
* 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。
|
|
183
|
-
*
|
|
223
|
+
* 按 request.model 路由到对应 Provider 的 endpoint;手写 fetch + SSE,无第三方依赖。
|
|
184
224
|
*/
|
|
185
|
-
export function createOpenAITransport(options: OpenAITransportOptions):
|
|
186
|
-
const {
|
|
225
|
+
export function createOpenAITransport(options: OpenAITransportOptions): OpenAITransport {
|
|
226
|
+
const { providers, headers: globalHeaders, body: globalBody } = options
|
|
227
|
+
const { models, byModelId } = flattenModels(providers)
|
|
228
|
+
const defaultModel = models[0]!.id
|
|
187
229
|
|
|
188
|
-
|
|
189
|
-
|
|
230
|
+
const send: ChatTransport = async (
|
|
231
|
+
request: ChatTransportRequest,
|
|
232
|
+
handlers: ChatTransportHandlers
|
|
233
|
+
) => {
|
|
234
|
+
const { messages, systemPrompt, tools, signal, reasoningLevel } = request
|
|
235
|
+
const modelId = request.model ?? defaultModel
|
|
236
|
+
const provider = byModelId.get(modelId)
|
|
237
|
+
|
|
238
|
+
if (!provider) {
|
|
239
|
+
handlers.onError?.(new Error(`未找到模型 "${modelId}" 对应的 Provider`))
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const requestBody: Record<string, unknown> = {
|
|
244
|
+
...globalBody,
|
|
245
|
+
model: modelId,
|
|
246
|
+
stream: true,
|
|
247
|
+
messages: toOpenAIMessages(messages, systemPrompt),
|
|
248
|
+
...(tools?.length ? { tools: toOpenAITools(tools) } : {})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (reasoningLevel) {
|
|
252
|
+
const apply = provider.applyReasoning ?? defaultApplyReasoning
|
|
253
|
+
apply(reasoningLevel, requestBody)
|
|
254
|
+
}
|
|
190
255
|
|
|
191
256
|
let response: Response
|
|
192
257
|
try {
|
|
193
|
-
response = await fetch(endpoint, {
|
|
258
|
+
response = await fetch(provider.endpoint, {
|
|
194
259
|
method: 'POST',
|
|
195
260
|
signal,
|
|
196
261
|
headers: {
|
|
197
262
|
'Content-Type': 'application/json',
|
|
198
|
-
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
|
199
|
-
...
|
|
263
|
+
...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}),
|
|
264
|
+
...globalHeaders,
|
|
265
|
+
...provider.headers
|
|
200
266
|
},
|
|
201
|
-
body: JSON.stringify(
|
|
202
|
-
model,
|
|
203
|
-
stream: true,
|
|
204
|
-
...body,
|
|
205
|
-
messages: toOpenAIMessages(messages, systemPrompt),
|
|
206
|
-
...(tools?.length ? { tools: toOpenAITools(tools) } : {})
|
|
207
|
-
})
|
|
267
|
+
body: JSON.stringify(requestBody)
|
|
208
268
|
})
|
|
209
269
|
} catch (error) {
|
|
210
270
|
if (!signal.aborted) {
|
|
@@ -234,4 +294,6 @@ export function createOpenAITransport(options: OpenAITransportOptions): ChatTran
|
|
|
234
294
|
}
|
|
235
295
|
}
|
|
236
296
|
}
|
|
297
|
+
|
|
298
|
+
return Object.assign(send, { models, defaultModel })
|
|
237
299
|
}
|
package/src/chat/types.ts
CHANGED
|
@@ -57,6 +57,16 @@ export interface ChatMessage {
|
|
|
57
57
|
status?: 'streaming' | 'done' | 'error' | 'aborted'
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/** 队列中的待发送消息(会话进行中提交的消息按序排队) */
|
|
61
|
+
export interface ChatQueuedMessage {
|
|
62
|
+
/** 队列项 id */
|
|
63
|
+
id: string
|
|
64
|
+
/** 消息内容 */
|
|
65
|
+
content: string
|
|
66
|
+
/** 携带的附件 */
|
|
67
|
+
attachments?: ChatAttachment[]
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/** 工具执行上下文 */
|
|
61
71
|
export interface ChatToolContext {
|
|
62
72
|
/** 本次工具调用 */
|
|
@@ -85,10 +95,36 @@ export interface ChatTool<A = any> {
|
|
|
85
95
|
/**
|
|
86
96
|
* 自定义工具卡片内容渲染(组件或渲染函数),props 为 ChatToolRenderProps。
|
|
87
97
|
* 设置后替换卡片 body 的默认参数/结果展示;优先级高于 tool-<name> 插槽。
|
|
98
|
+
* renderTo 为 'panel' 时改为在右侧侧边面板中渲染。
|
|
88
99
|
*/
|
|
89
100
|
render?: Component
|
|
90
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* 渲染位置。缺省 'inline':render 组件展示在会话内的工具卡片中;
|
|
103
|
+
* 'panel':render 组件展示在对话区右侧的侧边面板(新调用自动打开面板,
|
|
104
|
+
* 工具卡片内仅保留「查看面板」入口,面板与会话区宽度可拖拽调节)。
|
|
105
|
+
* 适合打开后台页面、表单、图表、列表等需要较大交互区域的工具。
|
|
106
|
+
*/
|
|
107
|
+
renderTo?: 'inline' | 'panel'
|
|
108
|
+
/**
|
|
109
|
+
* 侧边面板默认宽度(px,最小 320),仅 renderTo: 'panel' 时生效。
|
|
110
|
+
* 聚焦到该工具的调用时应用;缺省时面板打开取「容器宽 - 860」(即默认尽可能大,
|
|
111
|
+
* 给会话区保留 860px);面板已打开时切换聚焦保持当前宽度(含用户拖拽结果)。
|
|
112
|
+
*/
|
|
113
|
+
panelWidth?: number
|
|
114
|
+
/**
|
|
115
|
+
* 侧边面板标题,仅 renderTo: 'panel' 时生效。
|
|
116
|
+
* 面板标题通常是「业务对象 + 动作」(如「编辑用户 · 张三」)而非工具名,
|
|
117
|
+
* 可传固定字符串,或传函数按本次调用的参数动态生成。缺省取 label ?? name。
|
|
118
|
+
*/
|
|
119
|
+
panelTitle?: string | ((toolCall: ChatToolCall) => string)
|
|
120
|
+
/** 执行完成后是否自动折叠。缺省:renderTo 为 'panel' 时为 true;否则设置了 render 时为 false,否则为 true */
|
|
91
121
|
autoCollapse?: boolean
|
|
122
|
+
/**
|
|
123
|
+
* 终结工具:执行成功后对话即结束,结果不再回灌模型生成额外文字(工具 UI 即最终答复)。
|
|
124
|
+
* 适合天气卡片这类"UI 即答案"的工具,需配合 render 或 tool-<name> 插槽提供完整结果 UI。
|
|
125
|
+
* 工具结果仍会记录到消息历史供后续轮次使用;执行失败/被拒绝时错误照常回灌模型。
|
|
126
|
+
*/
|
|
127
|
+
terminal?: boolean
|
|
92
128
|
/** 工具实现,返回值(或 Promise 返回值)会被 JSON 序列化后回灌给模型 */
|
|
93
129
|
execute: (args: A, ctx: ChatToolContext) => unknown
|
|
94
130
|
}
|
|
@@ -107,6 +143,10 @@ export interface ChatTransportRequest {
|
|
|
107
143
|
systemPrompt?: string
|
|
108
144
|
/** 可用工具 */
|
|
109
145
|
tools?: ChatTool[]
|
|
146
|
+
/** 选中的模型 id(内置 OpenAI transport 按此路由 Provider) */
|
|
147
|
+
model?: string
|
|
148
|
+
/** 选中的推理等级(不透明字符串,由 Provider.applyReasoning 写入请求体) */
|
|
149
|
+
reasoningLevel?: string
|
|
110
150
|
/** 中断信号 */
|
|
111
151
|
signal: AbortSignal
|
|
112
152
|
}
|