@veltra/utils 1.10.0 → 1.11.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.
@@ -0,0 +1,290 @@
1
+ import type { AiMessage, AiStreamHandlers, AiTokenUsage, AiTool } from './types'
2
+
3
+ /** 请求体构造选项 */
4
+ export interface OpenAIBodyOptions {
5
+ /** 模型 id */
6
+ model: string
7
+ /** 消息历史(内部消息模型,本函数转换为 wire 格式) */
8
+ messages: readonly AiMessage[]
9
+ /** 系统提示词,存在时作为首条 system 消息 */
10
+ systemPrompt?: string
11
+ /** 可用工具 */
12
+ tools?: readonly AiTool[]
13
+ /** 是否流式,缺省 false */
14
+ stream?: boolean
15
+ /** 推理等级(不透明字符串,由 applyReasoning 写入请求体) */
16
+ reasoningLevel?: string
17
+ /** 自定义推理等级写入(缺省写 OpenAI 兼容的 reasoning_effort) */
18
+ applyReasoning?(level: string, body: Record<string, unknown>): void
19
+ /** 额外请求体字段(展开在最前,同名字段以本函数写入的为准) */
20
+ extraBody?: Record<string, unknown>
21
+ }
22
+
23
+ /** OpenAI 兼容 wire 消息格式 */
24
+ interface OpenAIMessage {
25
+ role: string
26
+ content?: unknown
27
+ tool_calls?: unknown[]
28
+ tool_call_id?: string
29
+ }
30
+
31
+ function toOpenAIMessages(messages: readonly AiMessage[], systemPrompt?: string): OpenAIMessage[] {
32
+ const result: OpenAIMessage[] = []
33
+
34
+ if (systemPrompt) {
35
+ result.push({ role: 'system', content: systemPrompt })
36
+ }
37
+
38
+ for (const msg of messages) {
39
+ if (msg.role === 'user') {
40
+ if (msg.attachments?.length) {
41
+ const parts: unknown[] = []
42
+ if (msg.content) parts.push({ type: 'text', text: msg.content })
43
+ for (const att of msg.attachments) {
44
+ parts.push({ type: 'image_url', image_url: { url: att.dataUrl } })
45
+ }
46
+ result.push({ role: 'user', content: parts })
47
+ } else {
48
+ result.push({ role: 'user', content: msg.content })
49
+ }
50
+ } else if (msg.role === 'assistant') {
51
+ const item: OpenAIMessage = { role: 'assistant', content: msg.content || '' }
52
+ if (msg.toolCalls?.length) {
53
+ item.tool_calls = msg.toolCalls.map((call) => ({
54
+ id: call.id,
55
+ type: 'function',
56
+ function: { name: call.name, arguments: call.arguments }
57
+ }))
58
+ }
59
+ result.push(item)
60
+ } else if (msg.role === 'tool') {
61
+ result.push({ role: 'tool', tool_call_id: msg.toolCallId, content: msg.content })
62
+ }
63
+ }
64
+
65
+ return result
66
+ }
67
+
68
+ function toOpenAITools(tools: readonly AiTool[]): unknown[] {
69
+ return tools.map((tool) => ({
70
+ type: 'function',
71
+ function: { name: tool.name, description: tool.description, parameters: tool.parameters }
72
+ }))
73
+ }
74
+
75
+ /** 缺省:写入 OpenAI 兼容的 reasoning_effort */
76
+ function defaultApplyReasoning(level: string, body: Record<string, unknown>) {
77
+ body.reasoning_effort = level
78
+ }
79
+
80
+ function isRecord(value: unknown): value is Record<string, unknown> {
81
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
82
+ }
83
+
84
+ /** 非负有限整数;非法或缺省返回 undefined(不把缺失当成 0) */
85
+ function readCount(value: unknown): number | undefined {
86
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined
87
+ return Math.floor(value)
88
+ }
89
+
90
+ /**
91
+ * 从 OpenAI 兼容 usage 对象解析。
92
+ * 缓存字段只在源数据出现时写入;未命中可在「有命中 + prompt」时由差值得出。
93
+ */
94
+ function parseOpenAIUsage(raw: unknown): AiTokenUsage | null {
95
+ if (!isRecord(raw)) return null
96
+
97
+ const promptTokens = readCount(raw.prompt_tokens) ?? readCount(raw.input_tokens)
98
+ const completionTokens = readCount(raw.completion_tokens) ?? readCount(raw.output_tokens)
99
+ if (promptTokens == null && completionTokens == null) return null
100
+
101
+ const prompt = promptTokens ?? 0
102
+ const completion = completionTokens ?? 0
103
+ const usage: AiTokenUsage = {
104
+ promptTokens: prompt,
105
+ completionTokens: completion,
106
+ totalTokens: readCount(raw.total_tokens) ?? prompt + completion
107
+ }
108
+
109
+ const details = isRecord(raw.prompt_tokens_details) ? raw.prompt_tokens_details : undefined
110
+ const inputDetails = isRecord(raw.input_tokens_details) ? raw.input_tokens_details : undefined
111
+ const cacheHitTokens =
112
+ readCount(details?.cached_tokens) ??
113
+ readCount(inputDetails?.cached_tokens) ??
114
+ readCount(raw.prompt_cache_hit_tokens) ??
115
+ readCount(raw.cache_read_input_tokens) ??
116
+ readCount(raw.cached_tokens)
117
+ if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
118
+
119
+ const cacheMissTokens =
120
+ readCount(raw.prompt_cache_miss_tokens) ??
121
+ (cacheHitTokens != null && promptTokens != null
122
+ ? Math.max(0, promptTokens - cacheHitTokens)
123
+ : undefined)
124
+ if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
125
+
126
+ const cacheCreationTokens = readCount(raw.cache_creation_input_tokens)
127
+ if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
128
+
129
+ return usage
130
+ }
131
+
132
+ interface AccumulatedToolCall {
133
+ id: string
134
+ name: string
135
+ arguments: string
136
+ }
137
+
138
+ /**
139
+ * 构造 OpenAI 兼容 chat/completions 请求体。
140
+ * 流式时写入 `stream_options.include_usage` 以获取末包 usage。
141
+ */
142
+ export function buildOpenAIBody(options: OpenAIBodyOptions): Record<string, unknown> {
143
+ const { model, messages, systemPrompt, tools, reasoningLevel, applyReasoning, extraBody } =
144
+ options
145
+ const stream = options.stream ?? false
146
+ // 额外字段里的 stream_options 与 include_usage 合并,额外字段优先的部分仍保留
147
+ const extraStreamOptions = isRecord(extraBody?.stream_options)
148
+ ? extraBody.stream_options
149
+ : undefined
150
+
151
+ const body: Record<string, unknown> = {
152
+ ...extraBody,
153
+ model,
154
+ stream,
155
+ // 流式默认要末包 usage;OpenAI / DeepSeek 等兼容端点靠这个字段才会返回
156
+ ...(stream ? { stream_options: { ...extraStreamOptions, include_usage: true } } : {}),
157
+ messages: toOpenAIMessages(messages, systemPrompt),
158
+ ...(tools?.length ? { tools: toOpenAITools(tools) } : {})
159
+ }
160
+
161
+ if (reasoningLevel) {
162
+ const apply = applyReasoning ?? defaultApplyReasoning
163
+ apply(reasoningLevel, body)
164
+ }
165
+
166
+ return body
167
+ }
168
+
169
+ /**
170
+ * 逐行解析 OpenAI 兼容 SSE 流(`data:` 帧、`[DONE]`、content / reasoning 增量、
171
+ * tool_calls 分片累积、usage 解析)。
172
+ * 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。
173
+ * 读流抛出的异常原样向外传播,由调用方决定错误处理;signal 中断即停止读取。
174
+ */
175
+ export async function parseOpenAISSEStream(
176
+ body: ReadableStream<Uint8Array>,
177
+ signal: AbortSignal | undefined,
178
+ handlers: AiStreamHandlers
179
+ ): Promise<void> {
180
+ const reader = body.getReader()
181
+ const decoder = new TextDecoder()
182
+ let buffer = ''
183
+
184
+ // 按 OpenAI 流式协议中 tool_calls 的 index 累积参数片段
185
+ const accumulated = new Map<number, AccumulatedToolCall>()
186
+ let lastIndex = -1
187
+
188
+ const flushToolCalls = () => {
189
+ if (!handlers.onToolCall) return
190
+ for (const call of accumulated.values()) {
191
+ if (call.id && call.name) {
192
+ handlers.onToolCall({ id: call.id, name: call.name, arguments: call.arguments })
193
+ }
194
+ }
195
+ accumulated.clear()
196
+ }
197
+
198
+ const handleData = (data: string) => {
199
+ if (data === '[DONE]') return
200
+
201
+ let chunk: unknown
202
+ try {
203
+ chunk = JSON.parse(data)
204
+ } catch {
205
+ return
206
+ }
207
+ if (!isRecord(chunk)) return
208
+
209
+ const usage = parseOpenAIUsage(chunk.usage)
210
+ if (usage) handlers.onUsage?.(usage)
211
+
212
+ const choices = chunk.choices
213
+ const first = Array.isArray(choices) ? choices[0] : undefined
214
+ const delta = isRecord(first) ? first.delta : undefined
215
+ if (!isRecord(delta)) return
216
+
217
+ if (typeof delta.content === 'string' && delta.content) {
218
+ handlers.onTextDelta(delta.content)
219
+ }
220
+
221
+ // 兼容 reasoning_content(DeepSeek)与 reasoning(部分 OpenAI 兼容端点)
222
+ const reasoning = delta.reasoning_content ?? delta.reasoning
223
+ if (typeof reasoning === 'string' && reasoning) {
224
+ handlers.onReasoningDelta?.(reasoning)
225
+ }
226
+
227
+ if (Array.isArray(delta.tool_calls)) {
228
+ for (const tc of delta.tool_calls) {
229
+ if (!isRecord(tc)) continue
230
+ const index = typeof tc.index === 'number' ? tc.index : 0
231
+ // 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去
232
+ if (index > lastIndex && accumulated.size > 0) flushToolCalls()
233
+ lastIndex = Math.max(lastIndex, index)
234
+
235
+ let call = accumulated.get(index)
236
+ if (!call) {
237
+ call = { id: '', name: '', arguments: '' }
238
+ accumulated.set(index, call)
239
+ }
240
+ if (typeof tc.id === 'string') call.id = tc.id
241
+ const fn = isRecord(tc.function) ? tc.function : undefined
242
+ if (typeof fn?.name === 'string') call.name += fn.name
243
+ if (typeof fn?.arguments === 'string') call.arguments += fn.arguments
244
+ }
245
+ }
246
+ }
247
+
248
+ try {
249
+ // 递归泵取代替 while 循环,避免 await-in-loop
250
+ const pump = async (): Promise<void> => {
251
+ const { done, value } = await reader.read()
252
+ if (done || signal?.aborted) return
253
+
254
+ buffer += decoder.decode(value, { stream: true })
255
+
256
+ let newlineIndex: number
257
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
258
+ const line = buffer.slice(0, newlineIndex).trim()
259
+ buffer = buffer.slice(newlineIndex + 1)
260
+ if (line.startsWith('data:')) {
261
+ handleData(line.slice(5).trim())
262
+ }
263
+ }
264
+
265
+ return pump()
266
+ }
267
+
268
+ await pump()
269
+
270
+ if (buffer.trim().startsWith('data:')) {
271
+ handleData(buffer.trim().slice(5).trim())
272
+ }
273
+ } finally {
274
+ reader.releaseLock()
275
+ }
276
+
277
+ flushToolCalls()
278
+ }
279
+
280
+ /**
281
+ * 从非流式 chat/completions JSON 响应取完整回复文本。
282
+ * 响应形状不含可解析文本时返回 undefined。
283
+ */
284
+ export function parseOpenAICompletionText(json: unknown): string | undefined {
285
+ if (!isRecord(json)) return undefined
286
+ const choices = json.choices
287
+ const first = Array.isArray(choices) ? choices[0] : undefined
288
+ const message = isRecord(first) && isRecord(first.message) ? first.message : undefined
289
+ return typeof message?.content === 'string' ? message.content : undefined
290
+ }
@@ -0,0 +1,147 @@
1
+ import { buildOpenAIBody, parseOpenAICompletionText, parseOpenAISSEStream } from './openai'
2
+ import type { AiStreamHandlers, AiTextRequestOptions } from './types'
3
+
4
+ /** OpenAI 兼容请求的发送选项(body 用 buildOpenAIBody 构造) */
5
+ export interface OpenAIRequestOptions {
6
+ /** OpenAI 兼容 chat/completions 完整请求地址 */
7
+ url: string
8
+ /** Bearer Token,缺省不带 Authorization 头 */
9
+ apiKey?: string
10
+ /** 额外请求头(覆盖同名默认头) */
11
+ headers?: Record<string, string>
12
+ /** 请求体 */
13
+ body: Record<string, unknown>
14
+ /** 中断信号 */
15
+ signal?: AbortSignal
16
+ }
17
+
18
+ /** OpenAI 兼容流式请求回调 */
19
+ export interface OpenAIStreamCallbacks extends AiStreamHandlers {
20
+ /** 请求错误(网络错误、非 2xx、流中断);signal 中断时静默 */
21
+ onError?(error: Error): void
22
+ }
23
+
24
+ /**
25
+ * 发送 OpenAI 兼容 POST 并统一错误处理。
26
+ * 返回 null 表示失败(错误已报给 onError)或已中断(静默)。
27
+ */
28
+ async function postOpenAI(
29
+ options: OpenAIRequestOptions,
30
+ onError?: (error: Error) => void
31
+ ): Promise<Response | null> {
32
+ const { url, apiKey, headers, body, signal } = options
33
+
34
+ let response: Response
35
+ try {
36
+ response = await fetch(url, {
37
+ method: 'POST',
38
+ signal,
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
42
+ ...headers
43
+ },
44
+ body: JSON.stringify(body)
45
+ })
46
+ } catch (error) {
47
+ if (!signal?.aborted) {
48
+ onError?.(error instanceof Error ? error : new Error(String(error)))
49
+ }
50
+ return null
51
+ }
52
+
53
+ if (!response.ok) {
54
+ const text = await response.text().catch(() => '')
55
+ onError?.(new Error(`请求失败(${response.status}):${text || response.statusText}`))
56
+ return null
57
+ }
58
+
59
+ return response
60
+ }
61
+
62
+ /**
63
+ * OpenAI 兼容流式请求:fetch + SSE 解析。
64
+ * 手写 fetch,无第三方依赖;错误经 onError 报告,signal 中断静默。
65
+ *
66
+ * @returns 流是否完整读取(false 表示失败或已中断)
67
+ */
68
+ export async function requestOpenAIStream(
69
+ options: OpenAIRequestOptions,
70
+ callbacks: OpenAIStreamCallbacks
71
+ ): Promise<boolean> {
72
+ const response = await postOpenAI(options, callbacks.onError)
73
+ if (!response) return false
74
+
75
+ if (!response.body) {
76
+ callbacks.onError?.(new Error('响应不包含可读取的流'))
77
+ return false
78
+ }
79
+
80
+ try {
81
+ await parseOpenAISSEStream(response.body, options.signal, callbacks)
82
+ } catch (error) {
83
+ if (!options.signal?.aborted) {
84
+ callbacks.onError?.(error instanceof Error ? error : new Error(String(error)))
85
+ }
86
+ return false
87
+ }
88
+
89
+ return true
90
+ }
91
+
92
+ /**
93
+ * 面向组件的单配置请求入口。
94
+ * 用 `systemPrompt`(可选)+ `prompt` 组装 messages,按 `config.stream` 走流式 / 非流式:
95
+ * - 流式:增量逐段触发 onDelta,流完整结束后以拼接文本触发 onSuccess;
96
+ * - 非流式:解析 JSON 响应,以完整文本触发 onSuccess。
97
+ * 失败触发 onError;signal 中断不触发任何回调(静默)。
98
+ */
99
+ export async function requestAiText(options: AiTextRequestOptions): Promise<void> {
100
+ const { config, prompt, systemPrompt, signal, onDelta, onSuccess, onError } = options
101
+
102
+ const body = buildOpenAIBody({
103
+ model: config.model,
104
+ messages: [{ role: 'user', content: prompt }],
105
+ systemPrompt,
106
+ stream: config.stream ?? false,
107
+ reasoningLevel: config.reasoningLevel
108
+ })
109
+
110
+ if (config.stream) {
111
+ let text = ''
112
+ const completed = await requestOpenAIStream(
113
+ { url: config.url, apiKey: config.apiKey, body, signal },
114
+ {
115
+ onTextDelta(delta) {
116
+ text += delta
117
+ onDelta?.(delta)
118
+ },
119
+ onError
120
+ }
121
+ )
122
+ if (completed) onSuccess?.(text)
123
+ return
124
+ }
125
+
126
+ const response = await postOpenAI(
127
+ { url: config.url, apiKey: config.apiKey, body, signal },
128
+ onError
129
+ )
130
+ if (!response) return
131
+
132
+ let json: unknown
133
+ try {
134
+ json = await response.json()
135
+ } catch (error) {
136
+ onError?.(error instanceof Error ? error : new Error(String(error)))
137
+ return
138
+ }
139
+
140
+ const text = parseOpenAICompletionText(json)
141
+ if (text == null) {
142
+ onError?.(new Error('响应不包含可解析的文本内容'))
143
+ return
144
+ }
145
+
146
+ onSuccess?.(text)
147
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * AI 服务商配置(OpenAI 兼容 chat/completions)。
3
+ * 组件实例 prop 与全局配置(useConfig 的 ai 键)字段级合并后使用。
4
+ */
5
+ export interface AiProviderConfig {
6
+ /** OpenAI 兼容 chat/completions 的完整请求地址(语义同 @veltra/ai 的 endpoint) */
7
+ url: string
8
+ /** Bearer Token,缺省不带 Authorization 头 */
9
+ apiKey?: string
10
+ /** 模型 id */
11
+ model: string
12
+ /** 推理等级;缺省不写入请求体,提供时默认写 reasoning_effort */
13
+ reasoningLevel?: string
14
+ /** 是否流式返回,缺省 false(一次性返回完整文本) */
15
+ stream?: boolean
16
+ }
17
+
18
+ /** 一次完整的工具调用(SSE 参数分片在内部累积完整后抛出) */
19
+ export interface AiToolCall {
20
+ /** 调用 id(由模型生成) */
21
+ id: string
22
+ /** 工具名 */
23
+ name: string
24
+ /** 模型输出的原始 JSON 参数串 */
25
+ arguments: string
26
+ }
27
+
28
+ /**
29
+ * 请求侧消息的最小结构(内部消息模型转 wire 格式的入参形状)。
30
+ * @veltra/ai 的 ChatMessage 满足该形状,可直接传入。
31
+ */
32
+ export interface AiMessage {
33
+ role: 'user' | 'assistant' | 'tool'
34
+ content: string
35
+ /** 用户消息图片附件(转 OpenAI image_url 内容分片) */
36
+ attachments?: ReadonlyArray<{ dataUrl: string }>
37
+ /** assistant 消息上的工具调用列表 */
38
+ toolCalls?: ReadonlyArray<AiToolCall>
39
+ /** role 为 tool 时关联的工具调用 id */
40
+ toolCallId?: string
41
+ }
42
+
43
+ /**
44
+ * 请求侧工具定义的最小结构。
45
+ * @veltra/ai 的 ChatTool 满足该形状,可直接传入。
46
+ */
47
+ export interface AiTool {
48
+ /** 工具名(传给模型,需唯一) */
49
+ name: string
50
+ /** 工具描述(传给模型) */
51
+ description: string
52
+ /** 参数 JSON Schema(原样传给模型) */
53
+ parameters: Record<string, unknown>
54
+ }
55
+
56
+ /**
57
+ * 一次模型请求的 token 用量。
58
+ * 仅包含接口实际返回的字段;缓存类数字缺省为 undefined,不补 0。
59
+ */
60
+ export interface AiTokenUsage {
61
+ /** 输入 token */
62
+ promptTokens: number
63
+ /** 输出 token */
64
+ completionTokens: number
65
+ /** 合计 token */
66
+ totalTokens: number
67
+ /**
68
+ * 缓存命中。
69
+ * 来源:`prompt_tokens_details.cached_tokens` / `prompt_cache_hit_tokens` /
70
+ * `cache_read_input_tokens` / `cached_tokens`。
71
+ */
72
+ cacheHitTokens?: number
73
+ /**
74
+ * 缓存未命中。
75
+ * 来源:`prompt_cache_miss_tokens`;若无该字段但有命中与 prompt,则为 prompt − 命中。
76
+ */
77
+ cacheMissTokens?: number
78
+ /** 写入缓存(Anthropic 风格 `cache_creation_input_tokens`),有才有 */
79
+ cacheCreationTokens?: number
80
+ }
81
+
82
+ /** OpenAI 兼容 SSE 流事件回调(请求错误由请求层统一处理,不经此抛出) */
83
+ export interface AiStreamHandlers {
84
+ /** 文本增量 */
85
+ onTextDelta(delta: string): void
86
+ /** 思考内容增量 */
87
+ onReasoningDelta?(delta: string): void
88
+ /** 一次完整的工具调用(流式参数片段在内部累积完整后才抛出) */
89
+ onToolCall?(call: AiToolCall): void
90
+ /** 本次请求的 token 用量(流式末包);接口未返回时不调用,不填 0 充数 */
91
+ onUsage?(usage: AiTokenUsage): void
92
+ }
93
+
94
+ /** requestAiText 的入参:面向组件的单配置请求 */
95
+ export interface AiTextRequestOptions {
96
+ /** 服务商配置(url / apiKey / model / reasoningLevel / stream) */
97
+ config: AiProviderConfig
98
+ /** 用户提示词(作为 user 消息) */
99
+ prompt: string
100
+ /** 系统提示词,存在时作为首条 system 消息 */
101
+ systemPrompt?: string
102
+ /** 中断信号;中断后不触发任何回调(静默) */
103
+ signal?: AbortSignal
104
+ /** 文本增量,仅流式时逐段触发 */
105
+ onDelta?(delta: string): void
106
+ /** 成功:流式为全部增量拼接,非流式为完整响应文本 */
107
+ onSuccess?(text: string): void
108
+ /** 失败:网络错误、非 2xx 响应、流中断 */
109
+ onError?(error: Error): void
110
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export * from './ai'
2
+
1
3
  export * from './dom/class-name'
2
4
 
3
5
  export * from './dom/expand-transition'