@veltra/ai 2.2.1 → 2.2.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.
Files changed (41) hide show
  1. package/dist/chat/fold.js +14 -2
  2. package/dist/chat/fold.js.map +1 -1
  3. package/dist/chat/session.d.ts +13 -6
  4. package/dist/chat/session.js.map +1 -1
  5. package/dist/chat/transports/openai.d.ts +2 -1
  6. package/dist/chat/transports/openai.js +20 -213
  7. package/dist/chat/transports/openai.js.map +1 -1
  8. package/dist/chat/use-chat-session.js +3 -13
  9. package/dist/chat/use-chat-session.js.map +1 -1
  10. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  11. package/dist/components/ai-chat/approval-banner.js.map +1 -1
  12. package/dist/components/ai-chat/ask-question.js.map +1 -1
  13. package/dist/components/ai-chat/chat-input.js.map +1 -1
  14. package/dist/components/ai-chat/job-bar.js.map +1 -1
  15. package/dist/components/ai-chat/message-item.js.map +1 -1
  16. package/dist/components/ai-chat/message-list.js.map +1 -1
  17. package/dist/components/ai-chat/model-picker.js.map +1 -1
  18. package/dist/components/ai-chat/queue-list.js.map +1 -1
  19. package/dist/components/ai-chat/reasoning-picker.js.map +1 -1
  20. package/dist/components/ai-chat/side-panel.js.map +1 -1
  21. package/dist/components/ai-chat/tool-call.js.map +1 -1
  22. package/dist/components/ai-chat/tool-json.js.map +1 -1
  23. package/dist/components/ai-chat/turn-process.js.map +1 -1
  24. package/dist/components/ai-orb/ai-orb.js.map +1 -1
  25. package/package.json +3 -3
  26. package/src/chat/__test__/bedrock-harness-sim.test.ts +467 -0
  27. package/src/chat/__test__/fixtures/bedrock/harness.ts +735 -0
  28. package/src/chat/__test__/fixtures/bedrock/http.ts +117 -0
  29. package/src/chat/__test__/fixtures/bedrock/types.ts +15 -0
  30. package/src/chat/__test__/fold.test.ts +26 -1
  31. package/src/chat/__test__/session.test.ts +1 -1
  32. package/src/chat/__test__/use-chat.test.ts +6 -3
  33. package/src/chat/fold.ts +16 -1
  34. package/src/chat/session.ts +13 -6
  35. package/src/chat/transports/__test__/openai.test.ts +1 -1
  36. package/src/chat/transports/openai.ts +25 -288
  37. package/src/chat/use-chat-session.ts +4 -5
  38. package/src/components/ai-chat/__test__/ai-chat.test.ts +1 -1
  39. package/src/components/ai-chat/__test__/tool-icons.test.ts +1 -1
  40. package/src/components/ai-orb/__test__/ai-orb.test.ts +1 -1
  41. package/src/tools/ask-question/__test__/ask-question.test.ts +1 -1
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Vendored from bedrock web/src/api(自包含快照,避免测试依赖仓库外绝对路径)。
3
+ * 上游更新时同步此处;依赖面:@veltra/ai 类型 + ./http + ./types。
4
+ */
5
+ import { storage, storageKey } from '@cat-kit/fe'
6
+ import { HTTPClient, TokenPlugin, type HTTPClientPlugin } from '@cat-kit/http'
7
+
8
+ import type { ApiEnvelope } from './types'
9
+
10
+ const ACCESS_KEY = storageKey<string>('access_token')
11
+ /** Legacy keys cleaned on set/clear (refresh is HttpOnly cookie from server). */
12
+ const LEGACY_REFRESH_KEY = storageKey<string>('refresh_token')
13
+
14
+ export function getAccessToken(): string | null {
15
+ return storage.local.get(ACCESS_KEY)
16
+ }
17
+
18
+ export function setAccessToken(access: string): void {
19
+ storage.local.set(ACCESS_KEY, access)
20
+ storage.local.remove(LEGACY_REFRESH_KEY)
21
+ }
22
+
23
+ export function clearTokens(): void {
24
+ storage.local.remove(ACCESS_KEY)
25
+ storage.local.remove(LEGACY_REFRESH_KEY)
26
+ }
27
+
28
+ function isApiEnvelope(body: unknown): body is ApiEnvelope {
29
+ if (body === null || typeof body !== 'object') return false
30
+ const o = body as Record<string, unknown>
31
+ return typeof o.code === 'number' && typeof o.message === 'string'
32
+ }
33
+
34
+ function envelopeErrorMessage(envelope: ApiEnvelope | undefined, fallback: string): string {
35
+ const msg = envelope?.message || fallback
36
+ return envelope?.request_id ? `${msg} [${envelope.request_id}]` : msg
37
+ }
38
+
39
+ /**
40
+ * Unwraps Bedrock `{ code, message, data?, request_id? }` envelopes.
41
+ * Successful calls resolve with `response.body === data`.
42
+ * Must run after TokenPlugin so 401 refresh/retry happens first.
43
+ */
44
+ function BedrockEnvelopePlugin(): HTTPClientPlugin {
45
+ return {
46
+ name: 'bedrock-envelope',
47
+ afterRespond({ response }) {
48
+ const { code, body } = response
49
+
50
+ // Already unwrapped (e.g. TokenPlugin retry returned a processed response).
51
+ if (!isApiEnvelope(body)) {
52
+ if (code < 200 || code >= 300) {
53
+ throw new Error(`HTTP ${code}`)
54
+ }
55
+ return
56
+ }
57
+
58
+ if (code < 200 || code >= 300) {
59
+ throw new Error(envelopeErrorMessage(body, `HTTP ${code}`))
60
+ }
61
+ if (body.code !== 0) {
62
+ throw new Error(envelopeErrorMessage(body, 'request failed'))
63
+ }
64
+
65
+ return { ...response, body: body.data }
66
+ }
67
+ }
68
+ }
69
+
70
+ const envelopePlugin = BedrockEnvelopePlugin()
71
+
72
+ /** Bare client for login/refresh (no Bearer injection). credentials so Set-Cookie / Cookie work. */
73
+ export const bareHttp = new HTTPClient('/api/v1', {
74
+ timeout: 30_000,
75
+ credentials: true,
76
+ plugins: [envelopePlugin]
77
+ })
78
+
79
+ let onAuthExpired: (() => void) | null = null
80
+
81
+ export function setOnAuthExpired(cb: (() => void) | null): void {
82
+ onAuthExpired = cb
83
+ }
84
+
85
+ function expireSession(): void {
86
+ clearTokens()
87
+ onAuthExpired?.()
88
+ }
89
+
90
+ /** 401 → POST /auth/refresh (cookie) → write new access_token; TokenPlugin retries the request. */
91
+ async function refreshAccessToken(): Promise<void> {
92
+ try {
93
+ const { body } = await bareHttp.post<{ access_token: string }>('/auth/refresh', {})
94
+ if (!body?.access_token) {
95
+ throw new Error('refresh failed')
96
+ }
97
+ setAccessToken(body.access_token)
98
+ } catch (err) {
99
+ expireSession()
100
+ throw err
101
+ }
102
+ }
103
+
104
+ export const http = new HTTPClient('/api/v1', {
105
+ timeout: 30_000,
106
+ credentials: true,
107
+ plugins: [
108
+ TokenPlugin({
109
+ getter: () => getAccessToken(),
110
+ authType: 'Bearer',
111
+ maxRetries: 1,
112
+ onRefresh: refreshAccessToken,
113
+ shouldRefresh: (response) => response.code === 401
114
+ }),
115
+ envelopePlugin
116
+ ]
117
+ })
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Vendored from bedrock web/src/api(自包含快照,避免测试依赖仓库外绝对路径)。
3
+ * 上游更新时同步此处;依赖面:@veltra/ai 类型 + ./http + ./types。
4
+ */
5
+ /**
6
+ * bedrock web 端 API 类型的最小子集(仅本仿真测试消费的 PageResult)。
7
+ * 来源:bedrock 仓 web/src/api/types.ts,同步以字段演化为准。
8
+ */
9
+ export interface PageResult<T> {
10
+ items: T[]
11
+ total: number
12
+ page: number
13
+ page_size: number
14
+ total_pages: number
15
+ }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it } from 'vite-plus/test'
2
2
 
3
3
  import { createFoldState, foldSessionEvent } from '../fold'
4
4
  import type { ChatSessionEvent } from '../session'
@@ -45,6 +45,31 @@ describe('foldSessionEvent', () => {
45
45
  })
46
46
  })
47
47
 
48
+ it('history 的 assistant/message 不把流式消息降级为 done、不用更短快照覆盖内容', () => {
49
+ // 断线补拉:直播已流式输出,历史快照落后(甚至为空)
50
+ const lagging = foldAll([
51
+ { type: 'assistant/chunk', messageId: 'a1', seq: 1, delta: '命令输出' },
52
+ { type: 'assistant/message', messageId: 'a1', content: '', history: true }
53
+ ])
54
+ expect(lagging.messages[0]).toMatchObject({ content: '命令输出', status: 'streaming' })
55
+
56
+ // 断线期间服务端推进更多:历史快照比直播长时采用历史(直播漏了增量)
57
+ const ahead = foldAll([
58
+ { type: 'assistant/chunk', messageId: 'a1', seq: 1, delta: '命令' },
59
+ { type: 'assistant/message', messageId: 'a1', content: '命令输出为 slow-done', history: true }
60
+ ])
61
+ expect(ahead.messages[0]).toMatchObject({
62
+ content: '命令输出为 slow-done',
63
+ status: 'streaming'
64
+ })
65
+
66
+ // 已终态的消息不受 history 标记影响,正常被历史回放覆盖(首挂载播种路径)
67
+ const seeded = foldAll([
68
+ { type: 'assistant/message', messageId: 'a2', content: '历史答复', history: true }
69
+ ])
70
+ expect(seeded.messages[0]).toMatchObject({ content: '历史答复', status: 'done' })
71
+ })
72
+
48
73
  it('tool/call 追加 pending 的 ChatToolCall 并保留 view', () => {
49
74
  const view = { preview: 'ls' }
50
75
  const state = foldAll([
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vite-plus/test'
2
2
 
3
3
  import { createFoldState, foldSessionEvent } from '../fold'
4
4
  import * as chat from '../index'
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vite-plus/test'
2
2
  import { effectScope, nextTick, reactive } from 'vue'
3
3
 
4
4
  import * as toolsModule from '../../tools'
@@ -765,7 +765,7 @@ describe('useChat session', () => {
765
765
  spy.mockRestore()
766
766
  })
767
767
 
768
- it('abort 调 session.cancel;running 由事件与 onDisconnect 驱动', async () => {
768
+ it('abort 调 session.cancel;running 由事件驱动,断线不翻转', async () => {
769
769
  const { chat, adapter } = await setupSession()
770
770
  expect(chat.running.value).toBe(false)
771
771
 
@@ -782,11 +782,14 @@ describe('useChat session', () => {
782
782
  adapter.emit({ type: 'error', code: 'x', message: '失败' })
783
783
  expect(chat.running.value).toBe(false)
784
784
 
785
+ // 断线 ≠ 停止:running 保持乐观,由重连回放的 prompted / idle 收敛。
786
+ // 曾在此翻转 running=false,步骤间隙断线会让过程块在「已完成 / 思考中」间跳。
785
787
  adapter.emit({ type: 'running', running: true })
786
788
  adapter.disconnect()
787
789
  await vi.waitFor(() => {
788
- expect(chat.running.value).toBe(false)
790
+ expect(adapter.fetchHistory).toHaveBeenCalled()
789
791
  })
792
+ expect(chat.running.value).toBe(true)
790
793
  })
791
794
 
792
795
  it('队列只认 snapshot;enqueue / startQueued / removeQueued / regenerate 为 no-op', async () => {
package/src/chat/fold.ts CHANGED
@@ -161,12 +161,27 @@ export function foldSessionEvent(state: ChatFoldState, event: ChatSessionEvent):
161
161
  break
162
162
  }
163
163
  case 'assistant/message': {
164
+ const current = next.messages.find((m) => m.id === event.messageId)
165
+ if (event.history && current && current.status === 'streaming') {
166
+ // 历史快照命中直播流式中的消息:快照可能落后(甚至为空),不能把消息
167
+ // 降级为 done 或用更短内容覆盖 —— 否则断线补拉会让消息在「完成 / 思考中」
168
+ // 之间来回翻转;终态由直播的 durable 帧(message_text / step 收尾)落定。
169
+ const longer = (a: string | undefined, b: string | undefined) =>
170
+ (b?.length ?? 0) > (a?.length ?? 0) ? b : a
171
+ next.messages = patchMessage(next.messages, event.messageId, {
172
+ role: 'assistant',
173
+ content: longer(current.content, event.content),
174
+ reasoning: longer(current.reasoning, event.reasoning),
175
+ toolCalls: event.toolCalls ?? current.toolCalls
176
+ })
177
+ break
178
+ }
164
179
  next.messages = patchMessage(next.messages, event.messageId, {
165
180
  role: 'assistant',
166
181
  status: 'done',
167
182
  content: event.content,
168
183
  reasoning: event.reasoning,
169
- toolCalls: event.toolCalls ?? next.messages.find((m) => m.id === event.messageId)?.toolCalls
184
+ toolCalls: event.toolCalls ?? current?.toolCalls
170
185
  })
171
186
  break
172
187
  }
@@ -1,36 +1,43 @@
1
1
  import type { AskQuestionItem } from '../tools'
2
2
  import type { ChatAttachment, ChatJob, ChatQueuedMessage, ChatToolCall } from './types'
3
3
 
4
- /** 归一化后的服务端会话事件(协议无关) */
4
+ /** 归一化后的服务端会话事件(协议无关)。
5
+ * seq 为持久帧序号:瞬态/历史回放事件省略(不参与乱序门),durable 终态事件携带。 */
5
6
  export type ChatSessionEvent =
6
7
  | {
7
8
  type: 'user/message'
8
9
  messageId: string
9
- seq: number
10
+ seq?: number
10
11
  content: string
11
12
  attachments?: ChatAttachment[]
12
13
  }
13
14
  | {
14
15
  type: 'assistant/chunk'
15
16
  messageId: string
16
- seq: number
17
+ seq?: number
17
18
  delta: string
18
19
  reasoningDelta?: string
19
20
  }
20
21
  | {
21
22
  type: 'assistant/message'
22
23
  messageId: string
23
- seq: number
24
+ seq?: number
24
25
  content: string
25
26
  reasoning?: string
26
27
  toolCalls?: ChatToolCall[]
28
+ /**
29
+ * 历史回放(REST 拉取/断线补拉)来源标记。
30
+ * 历史快照可能落后于直播流:fold 对流式中的消息不降级为 done、不用更短的
31
+ * 快照覆盖内容;直播(durable 终态帧)始终是状态权威。
32
+ */
33
+ history?: boolean
27
34
  }
28
35
  | {
29
36
  type: 'tool/call'
30
37
  callId: string
31
38
  name: string
32
39
  arguments: string
33
- seq: number
40
+ seq?: number
34
41
  view?: unknown
35
42
  }
36
43
  | {
@@ -39,7 +46,7 @@ export type ChatSessionEvent =
39
46
  status: 'success' | 'error' | 'rejected'
40
47
  result?: string
41
48
  error?: string
42
- seq: number
49
+ seq?: number
43
50
  view?: unknown
44
51
  }
45
52
  | {
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'
2
2
 
3
3
  import type { ChatTransportHandlers } from '../../types'
4
4
  import { createOpenAITransport } from '../openai'
@@ -1,12 +1,7 @@
1
+ import { buildOpenAIBody, requestOpenAIStream } from '@veltra/utils'
2
+
1
3
  import type { ChatModelOption, ChatProvider } from '../../providers'
2
- import type {
3
- ChatMessage,
4
- ChatTokenUsage,
5
- ChatTool,
6
- ChatTransport,
7
- ChatTransportHandlers,
8
- ChatTransportRequest
9
- } from '../types'
4
+ import type { ChatTransport, ChatTransportHandlers, ChatTransportRequest } from '../types'
10
5
 
11
6
  /** createOpenAITransport 的配置项(多 Provider) */
12
7
  export interface OpenAITransportOptions {
@@ -26,224 +21,6 @@ export type OpenAITransport = ChatTransport & {
26
21
  readonly defaultModel: string
27
22
  }
28
23
 
29
- interface OpenAIMessage {
30
- role: string
31
- content?: unknown
32
- tool_calls?: unknown[]
33
- tool_call_id?: string
34
- }
35
-
36
- function toOpenAIMessages(messages: ChatMessage[], systemPrompt?: string): OpenAIMessage[] {
37
- const result: OpenAIMessage[] = []
38
-
39
- if (systemPrompt) {
40
- result.push({ role: 'system', content: systemPrompt })
41
- }
42
-
43
- for (const msg of messages) {
44
- if (msg.role === 'user') {
45
- if (msg.attachments?.length) {
46
- const parts: unknown[] = []
47
- if (msg.content) parts.push({ type: 'text', text: msg.content })
48
- for (const att of msg.attachments) {
49
- parts.push({ type: 'image_url', image_url: { url: att.dataUrl } })
50
- }
51
- result.push({ role: 'user', content: parts })
52
- } else {
53
- result.push({ role: 'user', content: msg.content })
54
- }
55
- } else if (msg.role === 'assistant') {
56
- const item: OpenAIMessage = { role: 'assistant', content: msg.content || '' }
57
- if (msg.toolCalls?.length) {
58
- item.tool_calls = msg.toolCalls.map((call) => ({
59
- id: call.id,
60
- type: 'function',
61
- function: { name: call.name, arguments: call.arguments }
62
- }))
63
- }
64
- result.push(item)
65
- } else if (msg.role === 'tool') {
66
- result.push({ role: 'tool', tool_call_id: msg.toolCallId, content: msg.content })
67
- }
68
- }
69
-
70
- return result
71
- }
72
-
73
- function toOpenAITools(tools: ChatTool[]): unknown[] {
74
- return tools.map((tool) => ({
75
- type: 'function',
76
- function: { name: tool.name, description: tool.description, parameters: tool.parameters }
77
- }))
78
- }
79
-
80
- interface AccumulatedToolCall {
81
- id: string
82
- name: string
83
- arguments: string
84
- }
85
-
86
- function isRecord(value: unknown): value is Record<string, unknown> {
87
- return typeof value === 'object' && value !== null && !Array.isArray(value)
88
- }
89
-
90
- /** 非负有限整数;非法或缺省返回 undefined(不把缺失当成 0) */
91
- function readCount(value: unknown): number | undefined {
92
- if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined
93
- return Math.floor(value)
94
- }
95
-
96
- /**
97
- * 从 OpenAI 兼容 usage 对象解析。
98
- * 缓存字段只在源数据出现时写入;未命中可在「有命中 + prompt」时由差值得出。
99
- */
100
- function parseOpenAIUsage(raw: unknown): ChatTokenUsage | null {
101
- if (!isRecord(raw)) return null
102
-
103
- const promptTokens = readCount(raw.prompt_tokens) ?? readCount(raw.input_tokens)
104
- const completionTokens = readCount(raw.completion_tokens) ?? readCount(raw.output_tokens)
105
- if (promptTokens == null && completionTokens == null) return null
106
-
107
- const prompt = promptTokens ?? 0
108
- const completion = completionTokens ?? 0
109
- const usage: ChatTokenUsage = {
110
- promptTokens: prompt,
111
- completionTokens: completion,
112
- totalTokens: readCount(raw.total_tokens) ?? prompt + completion
113
- }
114
-
115
- const details = isRecord(raw.prompt_tokens_details) ? raw.prompt_tokens_details : undefined
116
- const inputDetails = isRecord(raw.input_tokens_details) ? raw.input_tokens_details : undefined
117
- const cacheHitTokens =
118
- readCount(details?.cached_tokens) ??
119
- readCount(inputDetails?.cached_tokens) ??
120
- readCount(raw.prompt_cache_hit_tokens) ??
121
- readCount(raw.cache_read_input_tokens) ??
122
- readCount(raw.cached_tokens)
123
- if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
124
-
125
- const cacheMissTokens =
126
- readCount(raw.prompt_cache_miss_tokens) ??
127
- (cacheHitTokens != null && promptTokens != null
128
- ? Math.max(0, promptTokens - cacheHitTokens)
129
- : undefined)
130
- if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
131
-
132
- const cacheCreationTokens = readCount(raw.cache_creation_input_tokens)
133
- if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
134
-
135
- return usage
136
- }
137
-
138
- /**
139
- * 逐行解析 SSE 流。
140
- * 兼容 OpenAI 及 DeepSeek 等 reasoning_content 风格的兼容端点。
141
- */
142
- async function parseSSE(
143
- body: ReadableStream<Uint8Array>,
144
- signal: AbortSignal,
145
- handlers: ChatTransportHandlers
146
- ): Promise<void> {
147
- const reader = body.getReader()
148
- const decoder = new TextDecoder()
149
- let buffer = ''
150
-
151
- // 按 OpenAI 流式协议中 tool_calls 的 index 累积参数片段
152
- const accumulated = new Map<number, AccumulatedToolCall>()
153
- let lastIndex = -1
154
-
155
- const flushToolCalls = () => {
156
- if (!handlers.onToolCall) return
157
- for (const call of accumulated.values()) {
158
- if (call.id && call.name) {
159
- handlers.onToolCall({ id: call.id, name: call.name, arguments: call.arguments })
160
- }
161
- }
162
- accumulated.clear()
163
- }
164
-
165
- const handleData = (data: string) => {
166
- if (data === '[DONE]') return
167
-
168
- let chunk: unknown
169
- try {
170
- chunk = JSON.parse(data)
171
- } catch {
172
- return
173
- }
174
- if (!isRecord(chunk)) return
175
-
176
- const usage = parseOpenAIUsage(chunk.usage)
177
- if (usage) handlers.onUsage?.(usage)
178
-
179
- const choices = chunk.choices
180
- const first = Array.isArray(choices) ? choices[0] : undefined
181
- const delta = isRecord(first) ? first.delta : undefined
182
- if (!isRecord(delta)) return
183
-
184
- if (typeof delta.content === 'string' && delta.content) {
185
- handlers.onTextDelta(delta.content)
186
- }
187
-
188
- // 兼容 reasoning_content(DeepSeek)与 reasoning(部分 OpenAI 兼容端点)
189
- const reasoning = delta.reasoning_content ?? delta.reasoning
190
- if (typeof reasoning === 'string' && reasoning) {
191
- handlers.onReasoningDelta?.(reasoning)
192
- }
193
-
194
- if (Array.isArray(delta.tool_calls)) {
195
- for (const tc of delta.tool_calls) {
196
- if (!isRecord(tc)) continue
197
- const index = typeof tc.index === 'number' ? tc.index : 0
198
- // 同一调用的参数分片共享 index;index 变大才说明前一个调用已完整,先抛出去
199
- if (index > lastIndex && accumulated.size > 0) flushToolCalls()
200
- lastIndex = Math.max(lastIndex, index)
201
-
202
- let call = accumulated.get(index)
203
- if (!call) {
204
- call = { id: '', name: '', arguments: '' }
205
- accumulated.set(index, call)
206
- }
207
- if (typeof tc.id === 'string') call.id = tc.id
208
- const fn = isRecord(tc.function) ? tc.function : undefined
209
- if (typeof fn?.name === 'string') call.name += fn.name
210
- if (typeof fn?.arguments === 'string') call.arguments += fn.arguments
211
- }
212
- }
213
- }
214
-
215
- try {
216
- // 递归泵取代替 while 循环,避免 await-in-loop
217
- const pump = async (): Promise<void> => {
218
- const { done, value } = await reader.read()
219
- if (done || signal.aborted) return
220
-
221
- buffer += decoder.decode(value, { stream: true })
222
-
223
- let newlineIndex: number
224
- while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
225
- const line = buffer.slice(0, newlineIndex).trim()
226
- buffer = buffer.slice(newlineIndex + 1)
227
- if (line.startsWith('data:')) {
228
- handleData(line.slice(5).trim())
229
- }
230
- }
231
-
232
- return pump()
233
- }
234
-
235
- await pump()
236
-
237
- if (buffer.trim().startsWith('data:')) {
238
- handleData(buffer.trim().slice(5).trim())
239
- }
240
- } finally {
241
- reader.releaseLock()
242
- }
243
-
244
- flushToolCalls()
245
- }
246
-
247
24
  /** 校验 providers 并展开扁平模型列表 */
248
25
  function flattenModels(providers: ChatProvider[]): {
249
26
  models: ChatModelOption[]
@@ -274,14 +51,10 @@ function flattenModels(providers: ChatProvider[]): {
274
51
  return { models, byModelId }
275
52
  }
276
53
 
277
- /** 缺省:写入 OpenAI 兼容的 reasoning_effort */
278
- function defaultApplyReasoning(level: string, body: Record<string, unknown>) {
279
- body.reasoning_effort = level
280
- }
281
-
282
54
  /**
283
55
  * 创建 OpenAI 兼容协议的传输层(chat/completions SSE)。
284
- * 按 request.model 路由到对应 Provider 的 endpoint;手写 fetch + SSE,无第三方依赖。
56
+ * 按 request.model 路由到对应 Provider 的 endpoint;
57
+ * 请求体构造、fetch、SSE 解析与错误处理复用 `@veltra/utils` 通用层。
285
58
  */
286
59
  export function createOpenAITransport(options: OpenAITransportOptions): OpenAITransport {
287
60
  const { providers, headers: globalHeaders, body: globalBody } = options
@@ -301,64 +74,28 @@ export function createOpenAITransport(options: OpenAITransportOptions): OpenAITr
301
74
  return
302
75
  }
303
76
 
304
- const extraStreamOptions = isRecord(globalBody?.stream_options)
305
- ? globalBody.stream_options
306
- : undefined
307
- const requestBody: Record<string, unknown> = {
308
- ...globalBody,
77
+ const requestBody = buildOpenAIBody({
309
78
  model: modelId,
79
+ messages,
80
+ systemPrompt,
81
+ tools,
310
82
  stream: true,
311
- // 流式默认要末包 usage;OpenAI / DeepSeek 等兼容端点靠这个字段才会返回
312
- stream_options: { ...extraStreamOptions, include_usage: true },
313
- messages: toOpenAIMessages(messages, systemPrompt),
314
- ...(tools?.length ? { tools: toOpenAITools(tools) } : {})
315
- }
316
-
317
- if (reasoningLevel) {
318
- const apply = provider.applyReasoning ?? defaultApplyReasoning
319
- apply(reasoningLevel, requestBody)
320
- }
321
-
322
- let response: Response
323
- try {
324
- response = await fetch(provider.endpoint, {
325
- method: 'POST',
326
- signal,
327
- headers: {
328
- 'Content-Type': 'application/json',
329
- ...(provider.apiKey ? { Authorization: `Bearer ${provider.apiKey}` } : {}),
330
- ...globalHeaders,
331
- ...provider.headers
332
- },
333
- body: JSON.stringify(requestBody)
334
- })
335
- } catch (error) {
336
- if (!signal.aborted) {
337
- handlers.onError?.(error instanceof Error ? error : new Error(String(error)))
338
- }
339
- return
340
- }
341
-
342
- if (!response.ok) {
343
- const text = await response.text().catch(() => '')
344
- handlers.onError?.(
345
- new Error(`请求失败(${response.status}):${text || response.statusText}`)
346
- )
347
- return
348
- }
349
-
350
- if (!response.body) {
351
- handlers.onError?.(new Error('响应不包含可读取的流'))
352
- return
353
- }
354
-
355
- try {
356
- await parseSSE(response.body, signal, handlers)
357
- } catch (error) {
358
- if (!signal.aborted) {
359
- handlers.onError?.(error instanceof Error ? error : new Error(String(error)))
360
- }
361
- }
83
+ reasoningLevel,
84
+ applyReasoning: provider.applyReasoning,
85
+ extraBody: globalBody
86
+ })
87
+
88
+ // fetch + SSE 解析 + 错误处理均由通用层完成;错误经 onError 报告,signal 中断静默
89
+ await requestOpenAIStream(
90
+ {
91
+ url: provider.endpoint,
92
+ apiKey: provider.apiKey,
93
+ headers: { ...globalHeaders, ...provider.headers },
94
+ body: requestBody,
95
+ signal
96
+ },
97
+ handlers
98
+ )
362
99
  }
363
100
 
364
101
  return Object.assign(send, { models, defaultModel })
@@ -58,14 +58,13 @@ export function createSessionRuntime(surface: SessionChatSurface) {
58
58
  fold = createFoldState()
59
59
  syncFold(fold, surface)
60
60
  let disposed = false
61
+ // 断线 ≠ 会话停止:不注册 onDisconnect 把 running 置 false(保持乐观,由
62
+ // 重连回放的 prompted / step_started / idle 帧收敛)。曾在此置 false ——
63
+ // 步骤间隙断线会把过程块折叠成「已完成」,重连随即又展开,消息列表在
64
+ // 「完成 / 思考中」之间来回跳。
61
65
  const dispose = transport.open({
62
66
  onEvent(event) {
63
67
  if (!disposed) apply(event)
64
- },
65
- onDisconnect() {
66
- if (disposed) return
67
- fold = { ...fold, running: false }
68
- surface.running.value = false
69
68
  }
70
69
  })
71
70
  opened = true
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vite-plus/test'
2
2
  import {
3
3
  createApp,
4
4
  defineComponent,
@@ -1,5 +1,5 @@
1
1
  import { EditPen, Folder, Internet, QuestionFilled, Terminal, Tools } from '@veltra/icons/normal'
2
- import { describe, expect, it } from 'vitest'
2
+ import { describe, expect, it } from 'vite-plus/test'
3
3
  import { defineComponent, h } from 'vue'
4
4
 
5
5
  import { FALLBACK_TOOL_ICON, resolveToolIcon } from '../tool-icons'
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
2
2
  import { createApp, h, nextTick } from 'vue'
3
3
 
4
4
  import UAiOrb from '../ai-orb.vue'