@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.
Files changed (74) hide show
  1. package/README.md +42 -0
  2. package/dist/chat/fold.d.ts +41 -0
  3. package/dist/chat/fold.js +208 -0
  4. package/dist/chat/fold.js.map +1 -0
  5. package/dist/chat/index.d.ts +4 -2
  6. package/dist/chat/session.d.ts +123 -0
  7. package/dist/chat/session.js +79 -0
  8. package/dist/chat/session.js.map +1 -0
  9. package/dist/chat/types.d.ts +38 -1
  10. package/dist/chat/use-chat-helpers.js +39 -0
  11. package/dist/chat/use-chat-helpers.js.map +1 -0
  12. package/dist/chat/use-chat-session.js +68 -0
  13. package/dist/chat/use-chat-session.js.map +1 -0
  14. package/dist/chat/use-chat.d.ts +50 -2
  15. package/dist/chat/use-chat.js +73 -39
  16. package/dist/chat/use-chat.js.map +1 -1
  17. package/dist/components/ai-chat/ai-chat.js +49 -14
  18. package/dist/components/ai-chat/ai-chat.js.map +1 -1
  19. package/dist/components/ai-chat/approval-banner.js +54 -0
  20. package/dist/components/ai-chat/approval-banner.js.map +1 -0
  21. package/dist/components/ai-chat/ask-question.js +32 -51
  22. package/dist/components/ai-chat/ask-question.js.map +1 -1
  23. package/dist/components/ai-chat/chat-input.js +9 -3
  24. package/dist/components/ai-chat/chat-input.js.map +1 -1
  25. package/dist/components/ai-chat/di.js.map +1 -1
  26. package/dist/components/ai-chat/job-bar.js +48 -0
  27. package/dist/components/ai-chat/job-bar.js.map +1 -0
  28. package/dist/components/ai-chat/message-list.js +5 -2
  29. package/dist/components/ai-chat/message-list.js.map +1 -1
  30. package/dist/components/ai-chat/queue-list.js +9 -3
  31. package/dist/components/ai-chat/queue-list.js.map +1 -1
  32. package/dist/components/ai-chat/side-panel.js +12 -30
  33. package/dist/components/ai-chat/side-panel.js.map +1 -1
  34. package/dist/components/ai-chat/style2.css +150 -8
  35. package/dist/components/ai-chat/tool-call.js +51 -40
  36. package/dist/components/ai-chat/tool-call.js.map +1 -1
  37. package/dist/components/ai-chat/tool-icons.js +31 -0
  38. package/dist/components/ai-chat/tool-icons.js.map +1 -0
  39. package/dist/components/ai-chat/tool-json.js +40 -0
  40. package/dist/components/ai-chat/tool-json.js.map +1 -0
  41. package/dist/components/ai-orb/style2.css +1 -1
  42. package/dist/index.d.ts +6 -4
  43. package/dist/index.js +3 -1
  44. package/dist/tools/ask-question/ask-question.js +48 -2
  45. package/dist/tools/ask-question/ask-question.js.map +1 -1
  46. package/dist/types/ai-chat.d.ts +17 -6
  47. package/package.json +7 -7
  48. package/src/chat/__test__/fold.test.ts +136 -0
  49. package/src/chat/__test__/session.test.ts +191 -0
  50. package/src/chat/__test__/use-chat.test.ts +268 -1
  51. package/src/chat/fold.ts +260 -0
  52. package/src/chat/index.ts +2 -0
  53. package/src/chat/session.ts +168 -0
  54. package/src/chat/types.ts +40 -0
  55. package/src/chat/use-chat-helpers.ts +39 -0
  56. package/src/chat/use-chat-session.ts +87 -0
  57. package/src/chat/use-chat.ts +90 -44
  58. package/src/components/ai-chat/__test__/ai-chat.test.ts +405 -7
  59. package/src/components/ai-chat/__test__/tool-icons.test.ts +33 -0
  60. package/src/components/ai-chat/ai-chat.vue +39 -4
  61. package/src/components/ai-chat/approval-banner.vue +37 -0
  62. package/src/components/ai-chat/ask-question.vue +38 -54
  63. package/src/components/ai-chat/chat-input.vue +10 -2
  64. package/src/components/ai-chat/di.ts +6 -4
  65. package/src/components/ai-chat/job-bar.vue +41 -0
  66. package/src/components/ai-chat/message-list.vue +4 -1
  67. package/src/components/ai-chat/queue-list.vue +3 -1
  68. package/src/components/ai-chat/side-panel.vue +15 -13
  69. package/src/components/ai-chat/style.scss +162 -6
  70. package/src/components/ai-chat/tool-call.vue +46 -34
  71. package/src/components/ai-chat/tool-icons.ts +33 -0
  72. package/src/components/ai-chat/tool-json.vue +52 -0
  73. package/src/tools/ask-question/ask-question.ts +53 -4
  74. package/src/types/ai-chat.ts +17 -5
@@ -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
@@ -1,3 +1,5 @@
1
1
  export * from './types'
2
2
  export * from './use-chat'
3
3
  export * from './transports'
4
+ export * from './session'
5
+ export * from './fold'
@@ -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
+ }