@veltra/ai 2.1.6 → 2.1.8

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 (71) 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/di.js.map +1 -1
  24. package/dist/components/ai-chat/job-bar.js +48 -0
  25. package/dist/components/ai-chat/job-bar.js.map +1 -0
  26. package/dist/components/ai-chat/message-list.js +5 -2
  27. package/dist/components/ai-chat/message-list.js.map +1 -1
  28. package/dist/components/ai-chat/queue-list.js +9 -3
  29. package/dist/components/ai-chat/queue-list.js.map +1 -1
  30. package/dist/components/ai-chat/side-panel.js +12 -30
  31. package/dist/components/ai-chat/side-panel.js.map +1 -1
  32. package/dist/components/ai-chat/style2.css +146 -8
  33. package/dist/components/ai-chat/tool-call.js +51 -40
  34. package/dist/components/ai-chat/tool-call.js.map +1 -1
  35. package/dist/components/ai-chat/tool-icons.js +31 -0
  36. package/dist/components/ai-chat/tool-icons.js.map +1 -0
  37. package/dist/components/ai-chat/tool-json.js +40 -0
  38. package/dist/components/ai-chat/tool-json.js.map +1 -0
  39. package/dist/components/ai-orb/style2.css +1 -1
  40. package/dist/index.d.ts +6 -4
  41. package/dist/index.js +3 -1
  42. package/dist/tools/ask-question/ask-question.js +48 -2
  43. package/dist/tools/ask-question/ask-question.js.map +1 -1
  44. package/dist/types/ai-chat.d.ts +17 -6
  45. package/package.json +7 -7
  46. package/src/chat/__test__/fold.test.ts +136 -0
  47. package/src/chat/__test__/session.test.ts +191 -0
  48. package/src/chat/__test__/use-chat.test.ts +268 -1
  49. package/src/chat/fold.ts +260 -0
  50. package/src/chat/index.ts +2 -0
  51. package/src/chat/session.ts +168 -0
  52. package/src/chat/types.ts +40 -0
  53. package/src/chat/use-chat-helpers.ts +39 -0
  54. package/src/chat/use-chat-session.ts +87 -0
  55. package/src/chat/use-chat.ts +90 -44
  56. package/src/components/ai-chat/__test__/ai-chat.test.ts +405 -7
  57. package/src/components/ai-chat/__test__/tool-icons.test.ts +33 -0
  58. package/src/components/ai-chat/ai-chat.vue +39 -4
  59. package/src/components/ai-chat/approval-banner.vue +37 -0
  60. package/src/components/ai-chat/ask-question.vue +38 -54
  61. package/src/components/ai-chat/di.ts +6 -4
  62. package/src/components/ai-chat/job-bar.vue +41 -0
  63. package/src/components/ai-chat/message-list.vue +4 -1
  64. package/src/components/ai-chat/queue-list.vue +3 -1
  65. package/src/components/ai-chat/side-panel.vue +15 -13
  66. package/src/components/ai-chat/style.scss +158 -6
  67. package/src/components/ai-chat/tool-call.vue +46 -34
  68. package/src/components/ai-chat/tool-icons.ts +33 -0
  69. package/src/components/ai-chat/tool-json.vue +52 -0
  70. package/src/tools/ask-question/ask-question.ts +53 -4
  71. 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
+ }
@@ -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 { createBuiltinTools } from '../tools'
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
- * 始终注入内置工具(如 askQuestion),无需经 props 传入。
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
- if (!props.transport) return
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 props.transport(
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