@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.
- package/dist/chat/fold.js +14 -2
- package/dist/chat/fold.js.map +1 -1
- package/dist/chat/session.d.ts +13 -6
- package/dist/chat/session.js.map +1 -1
- package/dist/chat/transports/openai.d.ts +2 -1
- package/dist/chat/transports/openai.js +20 -213
- package/dist/chat/transports/openai.js.map +1 -1
- package/dist/chat/use-chat-session.js +3 -13
- package/dist/chat/use-chat-session.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/approval-banner.js.map +1 -1
- package/dist/components/ai-chat/ask-question.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js.map +1 -1
- package/dist/components/ai-chat/job-bar.js.map +1 -1
- package/dist/components/ai-chat/message-item.js.map +1 -1
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/model-picker.js.map +1 -1
- package/dist/components/ai-chat/queue-list.js.map +1 -1
- package/dist/components/ai-chat/reasoning-picker.js.map +1 -1
- package/dist/components/ai-chat/side-panel.js.map +1 -1
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-chat/tool-json.js.map +1 -1
- package/dist/components/ai-chat/turn-process.js.map +1 -1
- package/dist/components/ai-orb/ai-orb.js.map +1 -1
- package/package.json +3 -3
- package/src/chat/__test__/bedrock-harness-sim.test.ts +467 -0
- package/src/chat/__test__/fixtures/bedrock/harness.ts +735 -0
- package/src/chat/__test__/fixtures/bedrock/http.ts +117 -0
- package/src/chat/__test__/fixtures/bedrock/types.ts +15 -0
- package/src/chat/__test__/fold.test.ts +26 -1
- package/src/chat/__test__/session.test.ts +1 -1
- package/src/chat/__test__/use-chat.test.ts +6 -3
- package/src/chat/fold.ts +16 -1
- package/src/chat/session.ts +13 -6
- package/src/chat/transports/__test__/openai.test.ts +1 -1
- package/src/chat/transports/openai.ts +25 -288
- package/src/chat/use-chat-session.ts +4 -5
- package/src/components/ai-chat/__test__/ai-chat.test.ts +1 -1
- package/src/components/ai-chat/__test__/tool-icons.test.ts +1 -1
- package/src/components/ai-orb/__test__/ai-orb.test.ts +1 -1
- 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 '
|
|
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 '
|
|
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
|
|
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(
|
|
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 ??
|
|
184
|
+
toolCalls: event.toolCalls ?? current?.toolCalls
|
|
170
185
|
})
|
|
171
186
|
break
|
|
172
187
|
}
|
package/src/chat/session.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
49
|
+
seq?: number
|
|
43
50
|
view?: unknown
|
|
44
51
|
}
|
|
45
52
|
| {
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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,5 +1,5 @@
|
|
|
1
1
|
import { EditPen, Folder, Internet, QuestionFilled, Terminal, Tools } from '@veltra/icons/normal'
|
|
2
|
-
import { describe, expect, it } from '
|
|
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'
|