@veltra/ai 2.1.6 → 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/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 +146 -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/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 +158 -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
|
@@ -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
|
+
}
|
package/src/chat/use-chat.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { useModel } from '@veltra/compositions'
|
|
2
2
|
import { reactive, ref, watch, type Ref } from 'vue'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import type { AskQuestionItem } from '../tools'
|
|
5
5
|
import type { AiChatEmits, AiChatProps } from '../types'
|
|
6
|
+
import type { ChatPendingApproval } from './fold'
|
|
7
|
+
import { isServerTransport, type ChatSessionTransport } from './session'
|
|
6
8
|
import type {
|
|
7
9
|
ChatAttachment,
|
|
10
|
+
ChatJob,
|
|
8
11
|
ChatMessage,
|
|
9
12
|
ChatQueuedMessage,
|
|
10
13
|
ChatTokenUsage,
|
|
11
14
|
ChatTool,
|
|
12
15
|
ChatToolCall
|
|
13
16
|
} from './types'
|
|
17
|
+
import { addTokenUsage, resolveTools, serializeResult } from './use-chat-helpers'
|
|
18
|
+
import { createSessionRuntime } from './use-chat-session'
|
|
14
19
|
|
|
15
20
|
export interface UseChatOptions {
|
|
16
21
|
props: AiChatProps
|
|
@@ -23,47 +28,10 @@ const uid = () => {
|
|
|
23
28
|
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
24
29
|
}
|
|
25
30
|
|
|
26
|
-
function serializeResult(result: unknown): string {
|
|
27
|
-
if (typeof result === 'string') return result
|
|
28
|
-
try {
|
|
29
|
-
return JSON.stringify(result) ?? 'null'
|
|
30
|
-
} catch {
|
|
31
|
-
return String(result)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** 内置工具 + 用户工具,同名时内置优先 */
|
|
36
|
-
function resolveTools(userTools?: ChatTool[]): ChatTool[] {
|
|
37
|
-
const builtins = createBuiltinTools()
|
|
38
|
-
const names = new Set(builtins.map((t) => t.name))
|
|
39
|
-
return [...builtins, ...(userTools ?? []).filter((t) => !names.has(t.name))]
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function mergeOptionalCount(a?: number, b?: number): number | undefined {
|
|
43
|
-
if (a == null && b == null) return undefined
|
|
44
|
-
return (a ?? 0) + (b ?? 0)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** 累加两次 usage;缓存字段只在至少一侧有值时保留 */
|
|
48
|
-
function addTokenUsage(a: ChatTokenUsage, b: ChatTokenUsage): ChatTokenUsage {
|
|
49
|
-
const usage: ChatTokenUsage = {
|
|
50
|
-
promptTokens: a.promptTokens + b.promptTokens,
|
|
51
|
-
completionTokens: a.completionTokens + b.completionTokens,
|
|
52
|
-
totalTokens: a.totalTokens + b.totalTokens
|
|
53
|
-
}
|
|
54
|
-
const cacheHitTokens = mergeOptionalCount(a.cacheHitTokens, b.cacheHitTokens)
|
|
55
|
-
if (cacheHitTokens != null) usage.cacheHitTokens = cacheHitTokens
|
|
56
|
-
const cacheMissTokens = mergeOptionalCount(a.cacheMissTokens, b.cacheMissTokens)
|
|
57
|
-
if (cacheMissTokens != null) usage.cacheMissTokens = cacheMissTokens
|
|
58
|
-
const cacheCreationTokens = mergeOptionalCount(a.cacheCreationTokens, b.cacheCreationTokens)
|
|
59
|
-
if (cacheCreationTokens != null) usage.cacheCreationTokens = cacheCreationTokens
|
|
60
|
-
return usage
|
|
61
|
-
}
|
|
62
|
-
|
|
63
31
|
/**
|
|
64
32
|
* AI 对话核心状态机:消息管理、流式追加、工具调用循环编排。
|
|
65
33
|
* 与 UI 解耦,ai-chat.vue 只负责渲染与交互转发。
|
|
66
|
-
*
|
|
34
|
+
* 函数型 transport 注入内置工具;session transport 不注入、不执行客户端工具。
|
|
67
35
|
*/
|
|
68
36
|
export function useChat(options: UseChatOptions) {
|
|
69
37
|
const { props, emit } = options
|
|
@@ -93,6 +61,28 @@ export function useChat(options: UseChatOptions) {
|
|
|
93
61
|
/** 最近一轮用户对话(含工具多轮请求)的 token;该轮无 usage 时为 null */
|
|
94
62
|
const lastTurnUsage = ref<ChatTokenUsage | null>(null)
|
|
95
63
|
|
|
64
|
+
/** session 下由 jobs/snapshot 整体替换;函数 transport 保持空数组 */
|
|
65
|
+
const jobs = ref<ChatJob[]>([])
|
|
66
|
+
const projections = ref<Record<string, unknown>>({})
|
|
67
|
+
const title = ref<string | null>(null)
|
|
68
|
+
const pendingApprovals = ref<ChatPendingApproval[]>([])
|
|
69
|
+
const pendingQuestion = ref<{ questions: AskQuestionItem[]; rpcId: string } | null>(null)
|
|
70
|
+
|
|
71
|
+
const sessionRuntime = createSessionRuntime({
|
|
72
|
+
messages,
|
|
73
|
+
queue,
|
|
74
|
+
jobs,
|
|
75
|
+
tokenUsage,
|
|
76
|
+
projections,
|
|
77
|
+
title,
|
|
78
|
+
running,
|
|
79
|
+
pendingApprovals,
|
|
80
|
+
pendingQuestion
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const session = (): ChatSessionTransport | null =>
|
|
84
|
+
isServerTransport(props.transport) ? props.transport : null
|
|
85
|
+
|
|
96
86
|
let abortController: AbortController | null = null
|
|
97
87
|
/** needsConfirm 工具的挂起确认器,key 为 toolCallId */
|
|
98
88
|
const confirmResolvers = new Map<string, (approved: boolean) => void>()
|
|
@@ -129,6 +119,25 @@ export function useChat(options: UseChatOptions) {
|
|
|
129
119
|
{ immediate: true }
|
|
130
120
|
)
|
|
131
121
|
|
|
122
|
+
// session:open + fetchHistory 播种;更换或卸载时 disposer
|
|
123
|
+
watch(
|
|
124
|
+
() => props.transport,
|
|
125
|
+
(transport, _prev, onCleanup) => {
|
|
126
|
+
if (!isServerTransport(transport)) return
|
|
127
|
+
sessionRuntime.attach(transport, onCleanup)
|
|
128
|
+
},
|
|
129
|
+
{ immediate: true }
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
// session 且已 open 时,模型切换走 selectModel
|
|
133
|
+
watch(model, (modelId) => {
|
|
134
|
+
const current = session()
|
|
135
|
+
if (!sessionRuntime.opened || !current || !modelId) return
|
|
136
|
+
const option = props.models?.find((m) => m.id === modelId)
|
|
137
|
+
if (!option) return
|
|
138
|
+
void current.selectModel(option.providerId, modelId)
|
|
139
|
+
})
|
|
140
|
+
|
|
132
141
|
/**
|
|
133
142
|
* 向父组件同步消息快照(流式 delta 过于频繁,不同步,只在关键节点同步)。
|
|
134
143
|
* 必须经 setter 赋值触发 emit,而非直接 emit 切片:setter 让本地数组与下发的
|
|
@@ -146,6 +155,11 @@ export function useChat(options: UseChatOptions) {
|
|
|
146
155
|
*/
|
|
147
156
|
const send = (content: string, attachments?: ChatAttachment[]) => {
|
|
148
157
|
if (!content.trim() && !attachments?.length) return
|
|
158
|
+
const current = session()
|
|
159
|
+
if (current) {
|
|
160
|
+
void current.send(content, attachments)
|
|
161
|
+
return
|
|
162
|
+
}
|
|
149
163
|
enqueue(content, attachments)
|
|
150
164
|
}
|
|
151
165
|
|
|
@@ -159,6 +173,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
159
173
|
beforeId?: string
|
|
160
174
|
): ChatQueuedMessage => {
|
|
161
175
|
const item: ChatQueuedMessage = { id: uid(), content, attachments }
|
|
176
|
+
if (session()) return item
|
|
162
177
|
const anchorIndex = beforeId ? queue.value.findIndex((q) => q.id === beforeId) : -1
|
|
163
178
|
if (anchorIndex === -1) {
|
|
164
179
|
queue.value.push(item)
|
|
@@ -177,6 +192,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
177
192
|
|
|
178
193
|
/** 立即执行队列中的某条:中断当前会话,该条插队为下一条(其余保持原顺序) */
|
|
179
194
|
const startQueued = (id: string) => {
|
|
195
|
+
if (session()) return
|
|
180
196
|
const item = removeQueued(id)
|
|
181
197
|
if (!item) return
|
|
182
198
|
|
|
@@ -193,6 +209,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
193
209
|
|
|
194
210
|
/** 从队列移除某条(返回被移除项;编辑场景由 UI 取回内容) */
|
|
195
211
|
const removeQueued = (id: string): ChatQueuedMessage | undefined => {
|
|
212
|
+
if (session()) return undefined
|
|
196
213
|
const index = queue.value.findIndex((item) => item.id === id)
|
|
197
214
|
if (index === -1) return undefined
|
|
198
215
|
const [item] = queue.value.splice(index, 1)
|
|
@@ -287,9 +304,10 @@ export function useChat(options: UseChatOptions) {
|
|
|
287
304
|
|
|
288
305
|
/** 单轮生成;存在工具调用时执行后递归进入下一轮(递归避免 await-in-loop) */
|
|
289
306
|
const runRound = async (controller: AbortController, depth: number): Promise<void> => {
|
|
290
|
-
|
|
307
|
+
const transport = props.transport
|
|
308
|
+
if (typeof transport !== 'function') return
|
|
291
309
|
const { signal } = controller
|
|
292
|
-
const tools = resolveTools(props.tools)
|
|
310
|
+
const tools = resolveTools(props.tools as ChatTool[] | undefined)
|
|
293
311
|
|
|
294
312
|
const assistant = reactive<ChatMessage>({
|
|
295
313
|
id: uid(),
|
|
@@ -303,7 +321,7 @@ export function useChat(options: UseChatOptions) {
|
|
|
303
321
|
let requestError: Error | null = null
|
|
304
322
|
|
|
305
323
|
try {
|
|
306
|
-
await
|
|
324
|
+
await transport(
|
|
307
325
|
{
|
|
308
326
|
// 排除刚追加的 assistant 占位消息
|
|
309
327
|
messages: messages.value.slice(0, -1),
|
|
@@ -395,17 +413,34 @@ export function useChat(options: UseChatOptions) {
|
|
|
395
413
|
|
|
396
414
|
/** 中断当前生成,挂起的工具确认按拒绝处理 */
|
|
397
415
|
const abort = () => {
|
|
416
|
+
const current = session()
|
|
417
|
+
if (current) {
|
|
418
|
+
void current.cancel()
|
|
419
|
+
return
|
|
420
|
+
}
|
|
398
421
|
abortController?.abort()
|
|
399
422
|
}
|
|
400
423
|
|
|
401
|
-
/** 响应 needsConfirm
|
|
424
|
+
/** 响应 needsConfirm 工具的用户确认;session 下按 callId 找到 rpcId 后 respond */
|
|
402
425
|
const respondToolCall = (toolCallId: string, approved: boolean) => {
|
|
426
|
+
const current = session()
|
|
427
|
+
if (current) {
|
|
428
|
+
const pending = pendingApprovals.value.find((a) => a.callId === toolCallId)
|
|
429
|
+
if (pending) void current.respond(pending.rpcId, approved)
|
|
430
|
+
return
|
|
431
|
+
}
|
|
403
432
|
confirmResolvers.get(toolCallId)?.(approved)
|
|
404
433
|
}
|
|
405
434
|
|
|
435
|
+
/** session 审批横幅 / 提问提交:直接 respond(rpcId) */
|
|
436
|
+
const respondSession = (rpcId: string, ok: boolean, value?: unknown) => {
|
|
437
|
+
const current = session()
|
|
438
|
+
if (current) void current.respond(rpcId, ok, value)
|
|
439
|
+
}
|
|
440
|
+
|
|
406
441
|
/** 重新生成:移除最后一条用户消息之后的所有消息,重新跑对话循环 */
|
|
407
442
|
const regenerate = () => {
|
|
408
|
-
if (running.value) return
|
|
443
|
+
if (session() || running.value) return
|
|
409
444
|
|
|
410
445
|
const list = messages.value
|
|
411
446
|
let lastUserIndex = -1
|
|
@@ -424,6 +459,11 @@ export function useChat(options: UseChatOptions) {
|
|
|
424
459
|
|
|
425
460
|
/** 清空消息、待发送队列与 token 统计,生成中则先中断 */
|
|
426
461
|
const clear = () => {
|
|
462
|
+
if (session()) {
|
|
463
|
+
sessionRuntime.resetLocal()
|
|
464
|
+
lastTurnUsage.value = null
|
|
465
|
+
return
|
|
466
|
+
}
|
|
427
467
|
abort()
|
|
428
468
|
queue.value = []
|
|
429
469
|
messages.value = []
|
|
@@ -438,13 +478,19 @@ export function useChat(options: UseChatOptions) {
|
|
|
438
478
|
reasoningLevel,
|
|
439
479
|
running,
|
|
440
480
|
queue,
|
|
481
|
+
jobs,
|
|
441
482
|
tokenUsage,
|
|
442
483
|
lastTurnUsage,
|
|
484
|
+
projections,
|
|
485
|
+
title,
|
|
486
|
+
pendingApprovals,
|
|
487
|
+
pendingQuestion,
|
|
443
488
|
send,
|
|
444
489
|
abort,
|
|
445
490
|
regenerate,
|
|
446
491
|
clear,
|
|
447
492
|
respondToolCall,
|
|
493
|
+
respondSession,
|
|
448
494
|
enqueue,
|
|
449
495
|
startQueued,
|
|
450
496
|
removeQueued
|