@veltra/ai 2.1.5 → 2.1.7
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/README.md +42 -0
- package/dist/chat/fold.d.ts +41 -0
- package/dist/chat/fold.js +208 -0
- package/dist/chat/fold.js.map +1 -0
- package/dist/chat/index.d.ts +4 -2
- package/dist/chat/session.d.ts +123 -0
- package/dist/chat/session.js +79 -0
- package/dist/chat/session.js.map +1 -0
- package/dist/chat/types.d.ts +38 -1
- package/dist/chat/use-chat-helpers.js +39 -0
- package/dist/chat/use-chat-helpers.js.map +1 -0
- package/dist/chat/use-chat-session.js +68 -0
- package/dist/chat/use-chat-session.js.map +1 -0
- package/dist/chat/use-chat.d.ts +50 -2
- package/dist/chat/use-chat.js +73 -39
- package/dist/chat/use-chat.js.map +1 -1
- package/dist/components/ai-chat/ai-chat.js +49 -14
- package/dist/components/ai-chat/ai-chat.js.map +1 -1
- package/dist/components/ai-chat/approval-banner.js +54 -0
- package/dist/components/ai-chat/approval-banner.js.map +1 -0
- package/dist/components/ai-chat/ask-question.js +32 -51
- package/dist/components/ai-chat/ask-question.js.map +1 -1
- package/dist/components/ai-chat/chat-input.js +9 -3
- 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/job-bar.js +48 -0
- package/dist/components/ai-chat/job-bar.js.map +1 -0
- package/dist/components/ai-chat/message-list.js +5 -2
- package/dist/components/ai-chat/message-list.js.map +1 -1
- package/dist/components/ai-chat/queue-list.js +9 -3
- package/dist/components/ai-chat/queue-list.js.map +1 -1
- package/dist/components/ai-chat/side-panel.js +12 -30
- package/dist/components/ai-chat/side-panel.js.map +1 -1
- package/dist/components/ai-chat/style2.css +150 -8
- package/dist/components/ai-chat/tool-call.js +51 -40
- package/dist/components/ai-chat/tool-call.js.map +1 -1
- package/dist/components/ai-chat/tool-icons.js +31 -0
- package/dist/components/ai-chat/tool-icons.js.map +1 -0
- package/dist/components/ai-chat/tool-json.js +40 -0
- package/dist/components/ai-chat/tool-json.js.map +1 -0
- package/dist/components/ai-orb/style2.css +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +3 -1
- package/dist/tools/ask-question/ask-question.js +48 -2
- package/dist/tools/ask-question/ask-question.js.map +1 -1
- package/dist/types/ai-chat.d.ts +17 -6
- package/package.json +7 -7
- package/src/chat/__test__/fold.test.ts +136 -0
- package/src/chat/__test__/session.test.ts +191 -0
- package/src/chat/__test__/use-chat.test.ts +268 -1
- package/src/chat/fold.ts +260 -0
- package/src/chat/index.ts +2 -0
- package/src/chat/session.ts +168 -0
- package/src/chat/types.ts +40 -0
- package/src/chat/use-chat-helpers.ts +39 -0
- package/src/chat/use-chat-session.ts +87 -0
- package/src/chat/use-chat.ts +90 -44
- package/src/components/ai-chat/__test__/ai-chat.test.ts +405 -7
- package/src/components/ai-chat/__test__/tool-icons.test.ts +33 -0
- package/src/components/ai-chat/ai-chat.vue +39 -4
- package/src/components/ai-chat/approval-banner.vue +37 -0
- package/src/components/ai-chat/ask-question.vue +38 -54
- package/src/components/ai-chat/chat-input.vue +10 -2
- package/src/components/ai-chat/di.ts +6 -4
- package/src/components/ai-chat/job-bar.vue +41 -0
- package/src/components/ai-chat/message-list.vue +4 -1
- package/src/components/ai-chat/queue-list.vue +3 -1
- package/src/components/ai-chat/side-panel.vue +15 -13
- package/src/components/ai-chat/style.scss +162 -6
- package/src/components/ai-chat/tool-call.vue +46 -34
- package/src/components/ai-chat/tool-icons.ts +33 -0
- package/src/components/ai-chat/tool-json.vue +52 -0
- package/src/tools/ask-question/ask-question.ts +53 -4
- package/src/types/ai-chat.ts +17 -5
package/src/chat/fold.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { AskQuestionItem } from '../tools'
|
|
2
|
+
import type { ChatSessionEvent } from './session'
|
|
3
|
+
import type { ChatJob, ChatMessage, ChatQueuedMessage, ChatTokenUsage, ChatToolCall } from './types'
|
|
4
|
+
|
|
5
|
+
function eventSeq(event: ChatSessionEvent): number | undefined {
|
|
6
|
+
return 'seq' in event ? event.seq : undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ChatPendingApproval {
|
|
10
|
+
approvalId: string
|
|
11
|
+
toolName: string
|
|
12
|
+
callId?: string
|
|
13
|
+
reason?: string
|
|
14
|
+
rpcId: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 历史回放与实时 onEvent 共用的折叠状态 */
|
|
18
|
+
export interface ChatFoldState {
|
|
19
|
+
lastSeq: number | null
|
|
20
|
+
messages: ChatMessage[]
|
|
21
|
+
queue: ChatQueuedMessage[]
|
|
22
|
+
jobs: ChatJob[]
|
|
23
|
+
tokenUsage: ChatTokenUsage | null
|
|
24
|
+
projections: Record<string, unknown>
|
|
25
|
+
title: string | null
|
|
26
|
+
running: boolean
|
|
27
|
+
pendingApprovals: ChatPendingApproval[]
|
|
28
|
+
pendingQuestion: { questions: AskQuestionItem[]; rpcId: string } | null
|
|
29
|
+
error: { code: string; message: string } | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createFoldState(): ChatFoldState {
|
|
33
|
+
return {
|
|
34
|
+
lastSeq: null,
|
|
35
|
+
messages: [],
|
|
36
|
+
queue: [],
|
|
37
|
+
jobs: [],
|
|
38
|
+
tokenUsage: null,
|
|
39
|
+
projections: {},
|
|
40
|
+
title: null,
|
|
41
|
+
running: false,
|
|
42
|
+
pendingApprovals: [],
|
|
43
|
+
pendingQuestion: null,
|
|
44
|
+
error: null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function mergeOptionalCount(a?: number, b?: number): number | undefined {
|
|
49
|
+
if (a == null && b == null) return undefined
|
|
50
|
+
return (a ?? 0) + (b ?? 0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addTokenUsage(a: ChatTokenUsage, b: ChatTokenUsage): ChatTokenUsage {
|
|
54
|
+
const usage: ChatTokenUsage = {
|
|
55
|
+
promptTokens: a.promptTokens + b.promptTokens,
|
|
56
|
+
completionTokens: a.completionTokens + b.completionTokens,
|
|
57
|
+
totalTokens: a.totalTokens + b.totalTokens
|
|
58
|
+
}
|
|
59
|
+
const cacheHitTokens = mergeOptionalCount(a.cacheHitTokens, b.cacheHitTokens)
|
|
60
|
+
if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
|
|
61
|
+
const cacheMissTokens = mergeOptionalCount(a.cacheMissTokens, b.cacheMissTokens)
|
|
62
|
+
if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
|
|
63
|
+
const cacheCreationTokens = mergeOptionalCount(a.cacheCreationTokens, b.cacheCreationTokens)
|
|
64
|
+
if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
|
|
65
|
+
return usage
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isTokenUsage(value: unknown): value is ChatTokenUsage {
|
|
69
|
+
if (value == null || typeof value !== 'object') return false
|
|
70
|
+
const usage = value as ChatTokenUsage
|
|
71
|
+
return (
|
|
72
|
+
typeof usage.promptTokens === 'number' &&
|
|
73
|
+
typeof usage.completionTokens === 'number' &&
|
|
74
|
+
typeof usage.totalTokens === 'number'
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function replaceAt<T>(list: T[], idx: number, item: T): T[] {
|
|
79
|
+
const next = list.slice()
|
|
80
|
+
next[idx] = item
|
|
81
|
+
return next
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function patchMessage(
|
|
85
|
+
messages: ChatMessage[],
|
|
86
|
+
id: string,
|
|
87
|
+
patch: Partial<ChatMessage>
|
|
88
|
+
): ChatMessage[] {
|
|
89
|
+
const idx = messages.findIndex((m) => m.id === id)
|
|
90
|
+
if (idx < 0) {
|
|
91
|
+
return [...messages, { id, role: 'assistant', content: '', status: 'streaming', ...patch }]
|
|
92
|
+
}
|
|
93
|
+
return replaceAt(messages, idx, { ...messages[idx]!, ...patch })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function appendToolCall(messages: ChatMessage[], call: ChatToolCall): ChatMessage[] {
|
|
97
|
+
const idx = messages.findLastIndex((m) => m.role === 'assistant')
|
|
98
|
+
if (idx < 0) {
|
|
99
|
+
return [
|
|
100
|
+
...messages,
|
|
101
|
+
{
|
|
102
|
+
id: `tool-host:${call.id}`,
|
|
103
|
+
role: 'assistant',
|
|
104
|
+
content: '',
|
|
105
|
+
status: 'streaming',
|
|
106
|
+
toolCalls: [call]
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
const target = messages[idx]!
|
|
111
|
+
return replaceAt(messages, idx, { ...target, toolCalls: [...(target.toolCalls ?? []), call] })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function updateToolCall(
|
|
115
|
+
messages: ChatMessage[],
|
|
116
|
+
callId: string,
|
|
117
|
+
patch: Partial<ChatToolCall>
|
|
118
|
+
): ChatMessage[] {
|
|
119
|
+
const idx = messages.findIndex((m) => m.toolCalls?.some((c) => c.id === callId))
|
|
120
|
+
if (idx < 0) return messages
|
|
121
|
+
const msg = messages[idx]!
|
|
122
|
+
const calls = (msg.toolCalls ?? []).slice()
|
|
123
|
+
const callIdx = calls.findIndex((c) => c.id === callId)
|
|
124
|
+
if (callIdx < 0) return messages
|
|
125
|
+
calls[callIdx] = { ...calls[callIdx]!, ...patch }
|
|
126
|
+
return replaceAt(messages, idx, { ...msg, toolCalls: calls })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 纯函数 fold:历史回放与实时 onEvent 走同一套。
|
|
131
|
+
* 后到的更小/重复 seq 不覆盖已应用事件。
|
|
132
|
+
*/
|
|
133
|
+
export function foldSessionEvent(state: ChatFoldState, event: ChatSessionEvent): ChatFoldState {
|
|
134
|
+
const seq = eventSeq(event)
|
|
135
|
+
if (seq != null && state.lastSeq != null && seq <= state.lastSeq) return state
|
|
136
|
+
|
|
137
|
+
const next: ChatFoldState = {
|
|
138
|
+
...state,
|
|
139
|
+
lastSeq: seq ?? state.lastSeq,
|
|
140
|
+
projections: { ...state.projections }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
switch (event.type) {
|
|
144
|
+
case 'user/message': {
|
|
145
|
+
if (next.messages.some((m) => m.id === event.messageId)) break
|
|
146
|
+
const userMessage: ChatMessage = { id: event.messageId, role: 'user', content: event.content }
|
|
147
|
+
if (event.attachments) userMessage.attachments = event.attachments
|
|
148
|
+
next.messages = [...next.messages, userMessage]
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
case 'assistant/chunk': {
|
|
152
|
+
const current = next.messages.find((m) => m.id === event.messageId)
|
|
153
|
+
next.messages = patchMessage(next.messages, event.messageId, {
|
|
154
|
+
role: 'assistant',
|
|
155
|
+
status: 'streaming',
|
|
156
|
+
content: (current?.content ?? '') + event.delta,
|
|
157
|
+
reasoning: event.reasoningDelta
|
|
158
|
+
? (current?.reasoning ?? '') + event.reasoningDelta
|
|
159
|
+
: current?.reasoning
|
|
160
|
+
})
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
case 'assistant/message': {
|
|
164
|
+
next.messages = patchMessage(next.messages, event.messageId, {
|
|
165
|
+
role: 'assistant',
|
|
166
|
+
status: 'done',
|
|
167
|
+
content: event.content,
|
|
168
|
+
reasoning: event.reasoning,
|
|
169
|
+
toolCalls: event.toolCalls ?? next.messages.find((m) => m.id === event.messageId)?.toolCalls
|
|
170
|
+
})
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
case 'tool/call': {
|
|
174
|
+
const call: ChatToolCall = {
|
|
175
|
+
id: event.callId,
|
|
176
|
+
name: event.name,
|
|
177
|
+
arguments: event.arguments,
|
|
178
|
+
status: 'pending'
|
|
179
|
+
}
|
|
180
|
+
if (event.view !== undefined) call.view = event.view
|
|
181
|
+
next.messages = appendToolCall(next.messages, call)
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
case 'tool/result': {
|
|
185
|
+
const patch: Partial<ChatToolCall> = { status: event.status }
|
|
186
|
+
if (event.result !== undefined) patch.result = event.result
|
|
187
|
+
if (event.error !== undefined) patch.error = event.error
|
|
188
|
+
if (event.view !== undefined) patch.view = event.view
|
|
189
|
+
next.messages = updateToolCall(next.messages, event.callId, patch)
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
case 'queue/snapshot': {
|
|
193
|
+
next.queue = event.items.slice()
|
|
194
|
+
break
|
|
195
|
+
}
|
|
196
|
+
case 'jobs/snapshot': {
|
|
197
|
+
next.jobs = event.jobs.slice()
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
case 'projection': {
|
|
201
|
+
if (event.key === 'tokenUsage' && isTokenUsage(event.value)) {
|
|
202
|
+
next.tokenUsage = next.tokenUsage
|
|
203
|
+
? addTokenUsage(next.tokenUsage, event.value)
|
|
204
|
+
: event.value
|
|
205
|
+
} else if (event.key === 'title' && typeof event.value === 'string') {
|
|
206
|
+
next.title = event.value
|
|
207
|
+
} else {
|
|
208
|
+
next.projections[event.key] = event.value
|
|
209
|
+
}
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
case 'running': {
|
|
213
|
+
next.running = event.running
|
|
214
|
+
break
|
|
215
|
+
}
|
|
216
|
+
case 'finish': {
|
|
217
|
+
next.running = false
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
case 'error': {
|
|
221
|
+
next.running = false
|
|
222
|
+
next.error = { code: event.code, message: event.message }
|
|
223
|
+
break
|
|
224
|
+
}
|
|
225
|
+
case 'approval/requested': {
|
|
226
|
+
if (event.callId) {
|
|
227
|
+
next.messages = updateToolCall(next.messages, event.callId, { status: 'awaiting-confirm' })
|
|
228
|
+
}
|
|
229
|
+
next.pendingApprovals = [
|
|
230
|
+
...next.pendingApprovals,
|
|
231
|
+
{
|
|
232
|
+
approvalId: event.approvalId,
|
|
233
|
+
toolName: event.toolName,
|
|
234
|
+
callId: event.callId,
|
|
235
|
+
reason: event.reason,
|
|
236
|
+
rpcId: event.rpcId
|
|
237
|
+
}
|
|
238
|
+
]
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
case 'approval/resolved': {
|
|
242
|
+
const hit = next.pendingApprovals.find((a) => a.approvalId === event.approvalId)
|
|
243
|
+
next.pendingApprovals = next.pendingApprovals.filter((a) => a.approvalId !== event.approvalId)
|
|
244
|
+
if (hit?.callId && event.outcome === 'rejected') {
|
|
245
|
+
next.messages = updateToolCall(next.messages, hit.callId, { status: 'rejected' })
|
|
246
|
+
}
|
|
247
|
+
break
|
|
248
|
+
}
|
|
249
|
+
case 'question/requested': {
|
|
250
|
+
next.pendingQuestion = { questions: event.questions, rpcId: event.rpcId }
|
|
251
|
+
break
|
|
252
|
+
}
|
|
253
|
+
case 'question/resolved': {
|
|
254
|
+
if (next.pendingQuestion?.rpcId === event.questionRpcId) next.pendingQuestion = null
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return next
|
|
260
|
+
}
|
package/src/chat/index.ts
CHANGED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { AskQuestionItem } from '../tools'
|
|
2
|
+
import type { ChatAttachment, ChatJob, ChatQueuedMessage, ChatToolCall } from './types'
|
|
3
|
+
|
|
4
|
+
/** 归一化后的服务端会话事件(协议无关) */
|
|
5
|
+
export type ChatSessionEvent =
|
|
6
|
+
| {
|
|
7
|
+
type: 'user/message'
|
|
8
|
+
messageId: string
|
|
9
|
+
seq: number
|
|
10
|
+
content: string
|
|
11
|
+
attachments?: ChatAttachment[]
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
type: 'assistant/chunk'
|
|
15
|
+
messageId: string
|
|
16
|
+
seq: number
|
|
17
|
+
delta: string
|
|
18
|
+
reasoningDelta?: string
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
type: 'assistant/message'
|
|
22
|
+
messageId: string
|
|
23
|
+
seq: number
|
|
24
|
+
content: string
|
|
25
|
+
reasoning?: string
|
|
26
|
+
toolCalls?: ChatToolCall[]
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
type: 'tool/call'
|
|
30
|
+
callId: string
|
|
31
|
+
name: string
|
|
32
|
+
arguments: string
|
|
33
|
+
seq: number
|
|
34
|
+
view?: unknown
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
type: 'tool/result'
|
|
38
|
+
callId: string
|
|
39
|
+
status: 'success' | 'error' | 'rejected'
|
|
40
|
+
result?: string
|
|
41
|
+
error?: string
|
|
42
|
+
seq: number
|
|
43
|
+
view?: unknown
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
type: 'approval/requested'
|
|
47
|
+
approvalId: string
|
|
48
|
+
toolName: string
|
|
49
|
+
callId?: string
|
|
50
|
+
reason?: string
|
|
51
|
+
rpcId: string
|
|
52
|
+
}
|
|
53
|
+
| { type: 'approval/resolved'; approvalId: string; outcome: string }
|
|
54
|
+
| { type: 'question/requested'; questions: AskQuestionItem[]; rpcId: string }
|
|
55
|
+
| { type: 'question/resolved'; questionRpcId: string; outcome: 'answered' | 'cancelled' }
|
|
56
|
+
| { type: 'queue/snapshot'; items: ChatQueuedMessage[] }
|
|
57
|
+
| { type: 'jobs/snapshot'; jobs: ChatJob[] }
|
|
58
|
+
| { type: 'projection'; key: string; value: unknown; seq: number }
|
|
59
|
+
| { type: 'running'; running: boolean }
|
|
60
|
+
| { type: 'finish' }
|
|
61
|
+
| { type: 'error'; code: string; message: string }
|
|
62
|
+
|
|
63
|
+
/** 服务端会话 transport:对象形态,与函数型 ChatTransport 互斥 */
|
|
64
|
+
export interface ChatSessionTransport {
|
|
65
|
+
readonly kind: 'session'
|
|
66
|
+
open(handlers: { onEvent(event: ChatSessionEvent): void; onDisconnect?(): void }): () => void
|
|
67
|
+
send(content: string, attachments?: ChatAttachment[]): Promise<void>
|
|
68
|
+
cancel(): Promise<void>
|
|
69
|
+
respond(rpcId: string, ok: boolean, value?: unknown): Promise<void>
|
|
70
|
+
fetchHistory(beforeSeq?: number): Promise<{ events: ChatSessionEvent[]; hasMore: boolean }>
|
|
71
|
+
selectModel(provider: string, model: string): Promise<void>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 协议翻译层:订阅事件流、把动作打到远端、拉历史。
|
|
76
|
+
* 时序校验 / dispose / 断线补拉 / in-flight 去重由 createServerTransport 完成。
|
|
77
|
+
*/
|
|
78
|
+
export interface ChatSessionAdapter {
|
|
79
|
+
subscribe(handlers: { onEvent(event: ChatSessionEvent): void; onDisconnect?(): void }): () => void
|
|
80
|
+
send(content: string, attachments?: ChatAttachment[]): Promise<void>
|
|
81
|
+
cancel(): Promise<void>
|
|
82
|
+
respond(rpcId: string, ok: boolean, value?: unknown): Promise<void>
|
|
83
|
+
fetchHistory(beforeSeq?: number): Promise<{ events: ChatSessionEvent[]; hasMore: boolean }>
|
|
84
|
+
selectModel(provider: string, model: string): Promise<void>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function eventSeq(event: ChatSessionEvent): number | undefined {
|
|
88
|
+
return 'seq' in event ? event.seq : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 仅带 kind: 'session' 的对象为 true;函数 / null / 无 kind 均为 false */
|
|
92
|
+
export function isServerTransport(t: unknown): t is ChatSessionTransport {
|
|
93
|
+
return typeof t === 'object' && t !== null && (t as { kind?: unknown }).kind === 'session'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function runExclusive(gate: { busy: boolean }, action: () => Promise<void>): Promise<void> {
|
|
97
|
+
if (gate.busy) return Promise.resolve()
|
|
98
|
+
gate.busy = true
|
|
99
|
+
return action().finally(() => {
|
|
100
|
+
gate.busy = false
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 用 adapter 包出 ChatSessionTransport。
|
|
106
|
+
* 包内完成 seq 校验、disposer、断线补拉、动作 in-flight 去重。
|
|
107
|
+
*/
|
|
108
|
+
export function createServerTransport(adapter: ChatSessionAdapter): ChatSessionTransport {
|
|
109
|
+
let lastSeq: number | undefined
|
|
110
|
+
const sendGate = { busy: false }
|
|
111
|
+
const cancelGate = { busy: false }
|
|
112
|
+
const respondGate = { busy: false }
|
|
113
|
+
const selectModelGate = { busy: false }
|
|
114
|
+
|
|
115
|
+
const deliver = (event: ChatSessionEvent, onEvent: (e: ChatSessionEvent) => void) => {
|
|
116
|
+
const seq = eventSeq(event)
|
|
117
|
+
if (seq != null && lastSeq != null && seq <= lastSeq) {
|
|
118
|
+
console.warn(`[ChatSessionTransport] 丢弃乱序事件 seq=${seq} lastSeq=${lastSeq}`)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (seq != null) lastSeq = seq
|
|
122
|
+
onEvent(event)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
kind: 'session',
|
|
127
|
+
open(handlers) {
|
|
128
|
+
let disposed = false
|
|
129
|
+
const unsubscribe = adapter.subscribe({
|
|
130
|
+
onEvent(event) {
|
|
131
|
+
if (!disposed) deliver(event, handlers.onEvent)
|
|
132
|
+
},
|
|
133
|
+
onDisconnect() {
|
|
134
|
+
if (disposed) return
|
|
135
|
+
void adapter.fetchHistory(lastSeq).then(
|
|
136
|
+
({ events }) => {
|
|
137
|
+
if (disposed) return
|
|
138
|
+
for (const event of events) deliver(event, handlers.onEvent)
|
|
139
|
+
handlers.onDisconnect?.()
|
|
140
|
+
},
|
|
141
|
+
() => {
|
|
142
|
+
if (!disposed) handlers.onDisconnect?.()
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
return () => {
|
|
148
|
+
disposed = true
|
|
149
|
+
unsubscribe()
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
send(content, attachments) {
|
|
153
|
+
return runExclusive(sendGate, () => adapter.send(content, attachments))
|
|
154
|
+
},
|
|
155
|
+
cancel() {
|
|
156
|
+
return runExclusive(cancelGate, () => adapter.cancel())
|
|
157
|
+
},
|
|
158
|
+
respond(rpcId, ok, value) {
|
|
159
|
+
return runExclusive(respondGate, () => adapter.respond(rpcId, ok, value))
|
|
160
|
+
},
|
|
161
|
+
fetchHistory(beforeSeq) {
|
|
162
|
+
return adapter.fetchHistory(beforeSeq)
|
|
163
|
+
},
|
|
164
|
+
selectModel(provider, model) {
|
|
165
|
+
return runExclusive(selectModelGate, () => adapter.selectModel(provider, model))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/chat/types.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface ChatToolCall {
|
|
|
35
35
|
result?: string
|
|
36
36
|
/** 执行失败信息 */
|
|
37
37
|
error?: string
|
|
38
|
+
/** 服务端下发的展示视图原文(session 模式可选) */
|
|
39
|
+
view?: unknown
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/** 聊天消息 */
|
|
@@ -129,6 +131,44 @@ export interface ChatTool<A = any> {
|
|
|
129
131
|
execute: (args: A, ctx: ChatToolContext) => unknown
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/**
|
|
135
|
+
* 服务端驱动模式下 tools 的合法形态:纯渲染元信息(执行在服务端)。
|
|
136
|
+
* 没有 execute / description / parameters / needsConfirm / terminal。
|
|
137
|
+
*/
|
|
138
|
+
export interface ChatToolMeta {
|
|
139
|
+
/** 与服务端 tool/call.name 匹配 */
|
|
140
|
+
name: string
|
|
141
|
+
/** 覆盖通用图标 */
|
|
142
|
+
icon?: Component
|
|
143
|
+
/** 工具显示名,缺省取 name */
|
|
144
|
+
label?: string
|
|
145
|
+
/** 自定义卡片/面板渲染,props 为 ChatToolRenderProps */
|
|
146
|
+
render?: Component
|
|
147
|
+
/** 渲染位置,语义同 ChatTool.renderTo */
|
|
148
|
+
renderTo?: 'inline' | 'panel'
|
|
149
|
+
/** 侧边面板默认宽度,语义同 ChatTool.panelWidth */
|
|
150
|
+
panelWidth?: number
|
|
151
|
+
/** 侧边面板标题,语义同 ChatTool.panelTitle */
|
|
152
|
+
panelTitle?: string | ((toolCall: ChatToolCall) => string)
|
|
153
|
+
/** 执行完成后是否自动折叠 */
|
|
154
|
+
autoCollapse?: boolean
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 作业条项状态:至少区分进行中与终态 */
|
|
158
|
+
export type ChatJobStatus = 'running' | 'done' | 'error'
|
|
159
|
+
|
|
160
|
+
/** 服务端下发的作业条项 */
|
|
161
|
+
export interface ChatJob {
|
|
162
|
+
/** 稳定 id */
|
|
163
|
+
id: string
|
|
164
|
+
/** 作业类型(用于图标映射) */
|
|
165
|
+
kind: string
|
|
166
|
+
/** 展示文案 */
|
|
167
|
+
label: string
|
|
168
|
+
/** 进行中或终态 */
|
|
169
|
+
status: ChatJobStatus
|
|
170
|
+
}
|
|
171
|
+
|
|
132
172
|
/** 工具自定义渲染组件 / 渲染函数的 props */
|
|
133
173
|
export interface ChatToolRenderProps {
|
|
134
174
|
/** 本次工具调用(含 status/arguments/result/error,可自行渲染进度与错误) */
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createBuiltinTools } from '../tools'
|
|
2
|
+
import type { ChatTokenUsage, ChatTool } from './types'
|
|
3
|
+
|
|
4
|
+
export function serializeResult(result: unknown): string {
|
|
5
|
+
if (typeof result === 'string') return result
|
|
6
|
+
try {
|
|
7
|
+
return JSON.stringify(result) ?? 'null'
|
|
8
|
+
} catch {
|
|
9
|
+
return String(result)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 内置工具 + 用户工具,同名时内置优先 */
|
|
14
|
+
export function resolveTools(userTools?: ChatTool[]): ChatTool[] {
|
|
15
|
+
const builtins = createBuiltinTools()
|
|
16
|
+
const names = new Set(builtins.map((t) => t.name))
|
|
17
|
+
return [...builtins, ...(userTools ?? []).filter((t) => !names.has(t.name))]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function mergeOptionalCount(a?: number, b?: number): number | undefined {
|
|
21
|
+
if (a == null && b == null) return undefined
|
|
22
|
+
return (a ?? 0) + (b ?? 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 累加两次 usage;缓存字段只在至少一侧有值时保留 */
|
|
26
|
+
export function addTokenUsage(a: ChatTokenUsage, b: ChatTokenUsage): ChatTokenUsage {
|
|
27
|
+
const usage: ChatTokenUsage = {
|
|
28
|
+
promptTokens: a.promptTokens + b.promptTokens,
|
|
29
|
+
completionTokens: a.completionTokens + b.completionTokens,
|
|
30
|
+
totalTokens: a.totalTokens + b.totalTokens
|
|
31
|
+
}
|
|
32
|
+
const cacheHitTokens = mergeOptionalCount(a.cacheHitTokens, b.cacheHitTokens)
|
|
33
|
+
if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
|
|
34
|
+
const cacheMissTokens = mergeOptionalCount(a.cacheMissTokens, b.cacheMissTokens)
|
|
35
|
+
if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
|
|
36
|
+
const cacheCreationTokens = mergeOptionalCount(a.cacheCreationTokens, b.cacheCreationTokens)
|
|
37
|
+
if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
|
|
38
|
+
return usage
|
|
39
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
import type { AskQuestionItem } from '../tools'
|
|
4
|
+
import {
|
|
5
|
+
createFoldState,
|
|
6
|
+
foldSessionEvent,
|
|
7
|
+
type ChatFoldState,
|
|
8
|
+
type ChatPendingApproval
|
|
9
|
+
} from './fold'
|
|
10
|
+
import type { ChatSessionEvent, ChatSessionTransport } from './session'
|
|
11
|
+
import type { ChatJob, ChatMessage, ChatQueuedMessage, ChatTokenUsage } from './types'
|
|
12
|
+
|
|
13
|
+
export interface SessionChatSurface {
|
|
14
|
+
messages: Ref<ChatMessage[]>
|
|
15
|
+
queue: Ref<ChatQueuedMessage[]>
|
|
16
|
+
jobs: Ref<ChatJob[]>
|
|
17
|
+
tokenUsage: Ref<ChatTokenUsage | null>
|
|
18
|
+
projections: Ref<Record<string, unknown>>
|
|
19
|
+
title: Ref<string | null>
|
|
20
|
+
running: Ref<boolean>
|
|
21
|
+
pendingApprovals: Ref<ChatPendingApproval[]>
|
|
22
|
+
pendingQuestion: Ref<{ questions: AskQuestionItem[]; rpcId: string } | null>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 把 fold 状态同步到 useChat 的渲染面 */
|
|
26
|
+
function syncFold(fold: ChatFoldState, surface: SessionChatSurface) {
|
|
27
|
+
surface.messages.value = fold.messages
|
|
28
|
+
surface.queue.value = fold.queue
|
|
29
|
+
surface.jobs.value = fold.jobs
|
|
30
|
+
surface.tokenUsage.value = fold.tokenUsage
|
|
31
|
+
surface.projections.value = fold.projections
|
|
32
|
+
surface.title.value = fold.title
|
|
33
|
+
surface.running.value = fold.running
|
|
34
|
+
surface.pendingApprovals.value = fold.pendingApprovals
|
|
35
|
+
surface.pendingQuestion.value = fold.pendingQuestion
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* session transport 的 open / fetchHistory / fold。
|
|
40
|
+
* 历史回放与实时 onEvent 走同一套 fold。
|
|
41
|
+
*/
|
|
42
|
+
export function createSessionRuntime(surface: SessionChatSurface) {
|
|
43
|
+
let fold = createFoldState()
|
|
44
|
+
let opened = false
|
|
45
|
+
|
|
46
|
+
const apply = (event: ChatSessionEvent) => {
|
|
47
|
+
fold = foldSessionEvent(fold, event)
|
|
48
|
+
syncFold(fold, surface)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
get opened() {
|
|
53
|
+
return opened
|
|
54
|
+
},
|
|
55
|
+
attach(transport: ChatSessionTransport, onCleanup: (fn: () => void) => void) {
|
|
56
|
+
// isServerTransport 只认 kind,不保证 open 存在
|
|
57
|
+
if (typeof transport.open !== 'function') return
|
|
58
|
+
fold = createFoldState()
|
|
59
|
+
syncFold(fold, surface)
|
|
60
|
+
let disposed = false
|
|
61
|
+
const dispose = transport.open({
|
|
62
|
+
onEvent(event) {
|
|
63
|
+
if (!disposed) apply(event)
|
|
64
|
+
},
|
|
65
|
+
onDisconnect() {
|
|
66
|
+
if (disposed) return
|
|
67
|
+
fold = { ...fold, running: false }
|
|
68
|
+
surface.running.value = false
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
opened = true
|
|
72
|
+
onCleanup(() => {
|
|
73
|
+
disposed = true
|
|
74
|
+
opened = false
|
|
75
|
+
dispose()
|
|
76
|
+
})
|
|
77
|
+
void transport.fetchHistory().then(({ events }) => {
|
|
78
|
+
if (disposed) return
|
|
79
|
+
for (const event of events) apply(event)
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
resetLocal() {
|
|
83
|
+
fold = createFoldState()
|
|
84
|
+
syncFold(fold, surface)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|