@huyooo/ai-chat-frontend-vue 0.1.6 → 0.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 +367 -0
- package/dist/adapter.d.ts +7 -7
- package/dist/adapter.d.ts.map +1 -1
- package/dist/components/ChatPanel.vue.d.ts +120 -9
- package/dist/components/ChatPanel.vue.d.ts.map +1 -1
- package/dist/components/common/ConfirmDialog.vue.d.ts +30 -0
- package/dist/components/common/ConfirmDialog.vue.d.ts.map +1 -0
- package/dist/components/common/CopyButton.vue.d.ts +18 -0
- package/dist/components/common/CopyButton.vue.d.ts.map +1 -0
- package/dist/components/common/IndexingSettings.vue.d.ts +3 -0
- package/dist/components/common/IndexingSettings.vue.d.ts.map +1 -0
- package/dist/components/common/SettingsPanel.vue.d.ts +16 -0
- package/dist/components/common/SettingsPanel.vue.d.ts.map +1 -0
- package/dist/components/common/Toast.vue.d.ts +18 -0
- package/dist/components/common/Toast.vue.d.ts.map +1 -0
- package/dist/components/common/ToggleSwitch.vue.d.ts +10 -0
- package/dist/components/common/ToggleSwitch.vue.d.ts.map +1 -0
- package/dist/components/{chat/ui → header}/ChatHeader.vue.d.ts +5 -3
- package/dist/components/header/ChatHeader.vue.d.ts.map +1 -0
- package/dist/components/input/AtFilePicker.vue.d.ts +21 -0
- package/dist/components/input/AtFilePicker.vue.d.ts.map +1 -0
- package/dist/components/{ChatInput.vue.d.ts → input/ChatInput.vue.d.ts} +16 -14
- package/dist/components/input/ChatInput.vue.d.ts.map +1 -0
- package/dist/components/input/DropdownSelector.vue.d.ts +42 -0
- package/dist/components/input/DropdownSelector.vue.d.ts.map +1 -0
- package/dist/components/input/ImagePreviewModal.vue.d.ts +17 -0
- package/dist/components/input/ImagePreviewModal.vue.d.ts.map +1 -0
- package/dist/components/input/at-views/AtBranchView.vue.d.ts +18 -0
- package/dist/components/input/at-views/AtBranchView.vue.d.ts.map +1 -0
- package/dist/components/input/at-views/AtBrowserView.vue.d.ts +18 -0
- package/dist/components/input/at-views/AtBrowserView.vue.d.ts.map +1 -0
- package/dist/components/input/at-views/AtChatsView.vue.d.ts +18 -0
- package/dist/components/input/at-views/AtChatsView.vue.d.ts.map +1 -0
- package/dist/components/input/at-views/AtDocsView.vue.d.ts +18 -0
- package/dist/components/input/at-views/AtDocsView.vue.d.ts.map +1 -0
- package/dist/components/input/at-views/AtFilesView.vue.d.ts +23 -0
- package/dist/components/input/at-views/AtFilesView.vue.d.ts.map +1 -0
- package/dist/components/input/at-views/AtTerminalsView.vue.d.ts +18 -0
- package/dist/components/input/at-views/AtTerminalsView.vue.d.ts.map +1 -0
- package/dist/components/message/MessageBubble.vue.d.ts +45 -0
- package/dist/components/message/MessageBubble.vue.d.ts.map +1 -0
- package/dist/components/message/PartsRenderer.vue.d.ts +15 -0
- package/dist/components/message/PartsRenderer.vue.d.ts.map +1 -0
- package/dist/components/message/WelcomeMessage.vue.d.ts +14 -0
- package/dist/components/message/WelcomeMessage.vue.d.ts.map +1 -0
- package/dist/components/message/blocks/CodeBlock.vue.d.ts +11 -0
- package/dist/components/message/blocks/CodeBlock.vue.d.ts.map +1 -0
- package/dist/components/{chat/SearchResultBlock.vue.d.ts → message/blocks/TextBlock.vue.d.ts} +3 -4
- package/dist/components/message/blocks/TextBlock.vue.d.ts.map +1 -0
- package/dist/components/message/blocks/index.d.ts +6 -0
- package/dist/components/message/blocks/index.d.ts.map +1 -0
- package/dist/components/message/parts/CollapsibleCard.vue.d.ts +45 -0
- package/dist/components/message/parts/CollapsibleCard.vue.d.ts.map +1 -0
- package/dist/components/{chat/ToolCallBlock.vue.d.ts → message/parts/ErrorPart.vue.d.ts} +4 -5
- package/dist/components/message/parts/ErrorPart.vue.d.ts.map +1 -0
- package/dist/components/{chat/ThinkingBlock.vue.d.ts → message/parts/ImagePart.vue.d.ts} +3 -3
- package/dist/components/message/parts/ImagePart.vue.d.ts.map +1 -0
- package/dist/components/message/parts/SearchPart.vue.d.ts +12 -0
- package/dist/components/message/parts/SearchPart.vue.d.ts.map +1 -0
- package/dist/components/{chat/messages/ExecutionSteps.vue.d.ts → message/parts/TextPart.vue.d.ts} +2 -9
- package/dist/components/message/parts/TextPart.vue.d.ts.map +1 -0
- package/dist/components/message/parts/ThinkingPart.vue.d.ts +12 -0
- package/dist/components/message/parts/ThinkingPart.vue.d.ts.map +1 -0
- package/dist/components/message/parts/ToolCallPart.vue.d.ts +19 -0
- package/dist/components/message/parts/ToolCallPart.vue.d.ts.map +1 -0
- package/dist/components/message/parts/ToolResultPart.vue.d.ts +14 -0
- package/dist/components/message/parts/ToolResultPart.vue.d.ts.map +1 -0
- package/dist/components/message/parts/index.d.ts +12 -0
- package/dist/components/message/parts/index.d.ts.map +1 -0
- package/dist/components/message/tool-results/DefaultToolResult.vue.d.ts +4 -0
- package/dist/components/message/tool-results/DefaultToolResult.vue.d.ts.map +1 -0
- package/dist/components/message/tool-results/SearchResults.vue.d.ts +4 -0
- package/dist/components/message/tool-results/SearchResults.vue.d.ts.map +1 -0
- package/dist/components/message/tool-results/WeatherCard.vue.d.ts +4 -0
- package/dist/components/message/tool-results/WeatherCard.vue.d.ts.map +1 -0
- package/dist/components/message/tool-results/index.d.ts +7 -0
- package/dist/components/message/tool-results/index.d.ts.map +1 -0
- package/dist/components/message/welcome-types.d.ts +28 -0
- package/dist/components/message/welcome-types.d.ts.map +1 -0
- package/dist/composables/useChat.d.ts +99 -44
- package/dist/composables/useChat.d.ts.map +1 -1
- package/dist/composables/useImageUpload.d.ts +55 -0
- package/dist/composables/useImageUpload.d.ts.map +1 -0
- package/dist/index.d.ts +25 -26
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +55871 -1252
- package/dist/style.css +1 -1
- package/dist/types/index.d.ts +113 -53
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/fileIcon.d.ts +13 -0
- package/dist/utils/fileIcon.d.ts.map +1 -0
- package/package.json +12 -6
- package/src/adapter.ts +12 -70
- package/src/components/ChatPanel.vue +329 -110
- package/src/components/common/ConfirmDialog.vue +208 -0
- package/src/components/common/CopyButton.vue +71 -0
- package/src/components/common/IndexingSettings.vue +580 -0
- package/src/components/common/SettingsPanel.vue +293 -0
- package/src/components/common/Toast.vue +90 -0
- package/src/components/common/ToggleSwitch.vue +75 -0
- package/src/components/{chat/ui → header}/ChatHeader.vue +170 -93
- package/src/components/input/AtFilePicker.vue +657 -0
- package/src/components/input/ChatInput.vue +653 -0
- package/src/components/input/DropdownSelector.vue +322 -0
- package/src/components/input/ImagePreviewModal.vue +238 -0
- package/src/components/input/at-views/AtBranchView.vue +63 -0
- package/src/components/input/at-views/AtBrowserView.vue +63 -0
- package/src/components/input/at-views/AtChatsView.vue +63 -0
- package/src/components/input/at-views/AtDocsView.vue +63 -0
- package/src/components/input/at-views/AtFilesView.vue +255 -0
- package/src/components/input/at-views/AtTerminalsView.vue +63 -0
- package/src/components/message/ContentRenderer.vue +61 -0
- package/src/components/message/MessageBubble.vue +411 -0
- package/src/components/message/PartsRenderer.vue +101 -0
- package/src/components/message/ToolResultRenderer.vue +27 -0
- package/src/components/message/WelcomeMessage.vue +308 -0
- package/src/components/message/blocks/CodeBlock.vue +113 -0
- package/src/components/message/blocks/TextBlock.vue +21 -0
- package/src/components/message/blocks/index.ts +6 -0
- package/src/components/message/parts/CollapsibleCard.vue +135 -0
- package/src/components/message/parts/ErrorPart.vue +51 -0
- package/src/components/message/parts/ImagePart.vue +98 -0
- package/src/components/message/parts/SearchPart.vue +101 -0
- package/src/components/message/parts/TextPart.vue +28 -0
- package/src/components/message/parts/ThinkingPart.vue +54 -0
- package/src/components/message/parts/ToolCallPart.vue +460 -0
- package/src/components/message/parts/ToolResultPart.vue +78 -0
- package/src/components/message/parts/index.ts +13 -0
- package/src/components/message/tool-results/DefaultToolResult.vue +43 -0
- package/src/components/message/tool-results/SearchResults.vue +133 -0
- package/src/components/message/tool-results/WeatherCard.vue +139 -0
- package/src/components/message/tool-results/index.ts +7 -0
- package/src/components/message/welcome-types.ts +47 -0
- package/src/composables/useChat.ts +807 -155
- package/src/composables/useImageUpload.ts +228 -0
- package/src/index.ts +93 -46
- package/src/styles.css +47 -0
- package/src/types/index.ts +146 -98
- package/src/utils/fileIcon.ts +49 -0
- package/dist/components/ChatInput.vue.d.ts.map +0 -1
- package/dist/components/chat/SearchResultBlock.vue.d.ts.map +0 -1
- package/dist/components/chat/ThinkingBlock.vue.d.ts.map +0 -1
- package/dist/components/chat/ToolCallBlock.vue.d.ts.map +0 -1
- package/dist/components/chat/messages/ExecutionSteps.vue.d.ts.map +0 -1
- package/dist/components/chat/messages/MessageBubble.vue.d.ts +0 -28
- package/dist/components/chat/messages/MessageBubble.vue.d.ts.map +0 -1
- package/dist/components/chat/ui/ChatHeader.vue.d.ts.map +0 -1
- package/dist/components/chat/ui/WelcomeMessage.vue.d.ts +0 -7
- package/dist/components/chat/ui/WelcomeMessage.vue.d.ts.map +0 -1
- package/dist/preload/preload.d.ts +0 -6
- package/dist/preload/preload.d.ts.map +0 -1
- package/src/components/ChatInput.vue +0 -649
- package/src/components/chat/SearchResultBlock.vue +0 -155
- package/src/components/chat/ThinkingBlock.vue +0 -109
- package/src/components/chat/ToolCallBlock.vue +0 -213
- package/src/components/chat/messages/ExecutionSteps.vue +0 -281
- package/src/components/chat/messages/MessageBubble.vue +0 -272
- package/src/components/chat/ui/WelcomeMessage.vue +0 -135
- package/src/preload/preload.ts +0 -79
|
@@ -1,63 +1,254 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useChat Composable
|
|
3
3
|
* 管理聊天状态和与后端的通信
|
|
4
|
-
*
|
|
4
|
+
*
|
|
5
|
+
* 新架构:
|
|
6
|
+
* - 使用 Map 存储每个会话的独立状态
|
|
7
|
+
* - 多会话可同时进行,互不干扰
|
|
8
|
+
* - 切换 tab 不会停止正在进行的请求
|
|
9
|
+
* - ContentPart 数组存储消息内容,支持流式渲染和自定义 UI
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
|
-
import { ref, shallowRef } from 'vue'
|
|
12
|
+
import { ref, shallowRef, computed, reactive, onMounted, onUnmounted } from 'vue'
|
|
8
13
|
import type {
|
|
9
14
|
ChatAdapter,
|
|
10
|
-
|
|
15
|
+
ChatEvent,
|
|
11
16
|
ChatMode,
|
|
12
17
|
SessionRecord,
|
|
13
18
|
MessageRecord,
|
|
19
|
+
AutoRunConfig,
|
|
14
20
|
} from '@huyooo/ai-chat-bridge-electron/renderer'
|
|
15
|
-
import {
|
|
16
|
-
|
|
21
|
+
import type {
|
|
22
|
+
ChatMessage,
|
|
23
|
+
ContentPart,
|
|
24
|
+
TextPart,
|
|
25
|
+
ThinkingPart,
|
|
26
|
+
SearchPart,
|
|
27
|
+
ToolCallPart,
|
|
28
|
+
ToolResultPart,
|
|
29
|
+
ErrorPart,
|
|
30
|
+
SearchResult,
|
|
31
|
+
} from '../types'
|
|
17
32
|
|
|
18
33
|
/** 生成唯一 ID */
|
|
19
34
|
function generateId(): string {
|
|
20
35
|
return Date.now().toString(36) + Math.random().toString(36).substr(2)
|
|
21
36
|
}
|
|
22
37
|
|
|
38
|
+
/** 获取消息的纯文本内容 */
|
|
39
|
+
function extractTextContent(parts: ContentPart[]): string {
|
|
40
|
+
return parts
|
|
41
|
+
.filter((p): p is TextPart => p.type === 'text')
|
|
42
|
+
.map(p => p.text)
|
|
43
|
+
.join('')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 解析工具调用结果 */
|
|
47
|
+
function parseToolResult(result: unknown): unknown | null {
|
|
48
|
+
if (result === undefined || result === null) return null
|
|
49
|
+
if (typeof result === 'string') {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(result)
|
|
52
|
+
} catch {
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
23
59
|
/** 转换存储的消息为显示格式 */
|
|
24
60
|
function convertToMessage(record: MessageRecord): ChatMessage {
|
|
61
|
+
let parts: ContentPart[] = []
|
|
62
|
+
|
|
63
|
+
if (record.steps) {
|
|
64
|
+
try {
|
|
65
|
+
const steps = JSON.parse(record.steps)
|
|
66
|
+
for (const step of steps) {
|
|
67
|
+
if (step.type === 'thinking') {
|
|
68
|
+
parts.push({
|
|
69
|
+
type: 'thinking',
|
|
70
|
+
text: step.text || '',
|
|
71
|
+
status: step.status || 'done',
|
|
72
|
+
duration: step.duration,
|
|
73
|
+
})
|
|
74
|
+
} else if (step.type === 'search') {
|
|
75
|
+
parts.push({
|
|
76
|
+
type: 'search',
|
|
77
|
+
query: step.query,
|
|
78
|
+
results: step.results,
|
|
79
|
+
status: step.status || 'done',
|
|
80
|
+
})
|
|
81
|
+
} else if (step.type === 'tool_call') {
|
|
82
|
+
parts.push({
|
|
83
|
+
type: 'tool_call',
|
|
84
|
+
id: step.id,
|
|
85
|
+
name: step.name,
|
|
86
|
+
args: step.args,
|
|
87
|
+
result: parseToolResult(step.result),
|
|
88
|
+
status: step.status || 'done',
|
|
89
|
+
})
|
|
90
|
+
} else if (step.type === 'text') {
|
|
91
|
+
parts.push({
|
|
92
|
+
type: 'text',
|
|
93
|
+
text: step.text || '',
|
|
94
|
+
})
|
|
95
|
+
} else if (step.type === 'error') {
|
|
96
|
+
parts.push({
|
|
97
|
+
type: 'error',
|
|
98
|
+
message: step.message || '',
|
|
99
|
+
category: step.category,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// 解析失败,忽略 steps
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 添加文本内容(如果 parts 中没有 text)
|
|
109
|
+
if (record.content && !parts.some(p => p.type === 'text')) {
|
|
110
|
+
parts.push({
|
|
111
|
+
type: 'text',
|
|
112
|
+
text: record.content,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 如果没有任何 parts,至少添加一个空文本
|
|
117
|
+
if (parts.length === 0) {
|
|
118
|
+
parts.push({ type: 'text', text: '' })
|
|
119
|
+
}
|
|
120
|
+
|
|
25
121
|
return {
|
|
26
122
|
id: record.id,
|
|
27
123
|
role: record.role,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
124
|
+
parts,
|
|
125
|
+
model: record.model || undefined,
|
|
126
|
+
mode: record.mode || undefined,
|
|
127
|
+
webSearchEnabled: record.webSearchEnabled ?? undefined,
|
|
128
|
+
thinkingEnabled: record.thinkingEnabled ?? undefined,
|
|
129
|
+
loading: false,
|
|
34
130
|
timestamp: record.timestamp,
|
|
35
131
|
}
|
|
36
132
|
}
|
|
37
133
|
|
|
134
|
+
/** 会话状态 - 每个会话独立的状态 */
|
|
135
|
+
interface SessionState {
|
|
136
|
+
messages: ChatMessage[]
|
|
137
|
+
isLoading: boolean
|
|
138
|
+
abortController: AbortController | null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 副作用定义 */
|
|
142
|
+
export interface SideEffect {
|
|
143
|
+
type: string
|
|
144
|
+
success: boolean
|
|
145
|
+
data?: unknown
|
|
146
|
+
message?: string
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 工具完成事件数据 */
|
|
150
|
+
export interface ToolCompleteEvent {
|
|
151
|
+
name: string
|
|
152
|
+
result: unknown
|
|
153
|
+
/**
|
|
154
|
+
* 工具声明的副作用列表
|
|
155
|
+
* 前端可根据此字段处理通知、刷新文件列表等
|
|
156
|
+
*/
|
|
157
|
+
sideEffects?: SideEffect[]
|
|
158
|
+
}
|
|
159
|
+
|
|
38
160
|
export interface UseChatOptions {
|
|
39
|
-
|
|
40
|
-
adapter?: ChatAdapter
|
|
41
|
-
/** 默认模型 */
|
|
161
|
+
adapter: ChatAdapter
|
|
42
162
|
defaultModel?: string
|
|
43
|
-
/** 默认模式 */
|
|
44
163
|
defaultMode?: ChatMode
|
|
164
|
+
onToolComplete?: (event: ToolCompleteEvent) => void
|
|
165
|
+
autoRunConfig?: AutoRunConfig
|
|
45
166
|
}
|
|
46
167
|
|
|
47
168
|
/**
|
|
48
169
|
* 聊天状态管理 Composable
|
|
170
|
+
*
|
|
171
|
+
* 核心架构:
|
|
172
|
+
* - sessionStates: Map<sessionId, SessionState> 存储每个会话的独立状态
|
|
173
|
+
* - 多会话可同时进行请求,互不干扰
|
|
174
|
+
* - 切换 tab 不会停止任何正在进行的请求
|
|
175
|
+
* - 通过 computed 属性暴露当前会话的状态
|
|
49
176
|
*/
|
|
50
|
-
export function useChat(options: UseChatOptions
|
|
177
|
+
export function useChat(options: UseChatOptions) {
|
|
51
178
|
const {
|
|
52
|
-
adapter
|
|
179
|
+
adapter,
|
|
53
180
|
defaultModel = 'anthropic/claude-opus-4.5',
|
|
54
181
|
defaultMode = 'agent',
|
|
182
|
+
onToolComplete,
|
|
183
|
+
// autoRunConfig 现在从数据库读取,不再从 props 传入
|
|
55
184
|
} = options
|
|
56
185
|
|
|
57
|
-
//
|
|
186
|
+
// ==================== 会话状态存储 ====================
|
|
187
|
+
// 每个会话的独立状态 Map
|
|
188
|
+
const sessionStates = reactive(new Map<string, SessionState>())
|
|
189
|
+
|
|
190
|
+
// 获取或创建会话状态
|
|
191
|
+
function getSessionState(sessionId: string): SessionState {
|
|
192
|
+
if (!sessionStates.has(sessionId)) {
|
|
193
|
+
sessionStates.set(sessionId, {
|
|
194
|
+
messages: [],
|
|
195
|
+
isLoading: false,
|
|
196
|
+
abortController: null,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
return sessionStates.get(sessionId)!
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ==================== 全局状态 ====================
|
|
58
203
|
const sessions = ref<SessionRecord[]>([])
|
|
59
204
|
const currentSessionId = ref<string | null>(null)
|
|
60
|
-
|
|
205
|
+
|
|
206
|
+
// ==================== 自动运行配置(从数据库读取) ====================
|
|
207
|
+
// 默认配置
|
|
208
|
+
const DEFAULT_AUTO_RUN_CONFIG: AutoRunConfig = {
|
|
209
|
+
mode: 'run-everything',
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const autoRunConfig = ref<AutoRunConfig>({ ...DEFAULT_AUTO_RUN_CONFIG })
|
|
213
|
+
|
|
214
|
+
/** 从数据库加载自动运行配置 */
|
|
215
|
+
async function loadAutoRunConfig() {
|
|
216
|
+
if (!adapter.getAllSettings) return
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const settings = await adapter.getAllSettings()
|
|
220
|
+
const configJson = settings['autoRunConfig']
|
|
221
|
+
|
|
222
|
+
if (configJson) {
|
|
223
|
+
const config = JSON.parse(configJson) as AutoRunConfig
|
|
224
|
+
// 合并配置,确保新增字段有默认值
|
|
225
|
+
autoRunConfig.value = { ...DEFAULT_AUTO_RUN_CONFIG, ...config }
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
console.error('[useChat] 加载 autoRunConfig 失败:', error)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 保存自动运行配置到数据库 */
|
|
233
|
+
async function saveAutoRunConfig(config: AutoRunConfig) {
|
|
234
|
+
if (!adapter.setSetting) return
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
// 创建纯对象副本用于序列化
|
|
238
|
+
const plainConfig: AutoRunConfig = {
|
|
239
|
+
mode: config.mode,
|
|
240
|
+
}
|
|
241
|
+
await adapter.setSetting('autoRunConfig', JSON.stringify(plainConfig))
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error('[useChat] 保存 autoRunConfig 失败:', error)
|
|
244
|
+
throw error
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 初始化时加载配置
|
|
249
|
+
onMounted(() => {
|
|
250
|
+
loadAutoRunConfig()
|
|
251
|
+
})
|
|
61
252
|
|
|
62
253
|
// 配置状态
|
|
63
254
|
const mode = ref<ChatMode>(defaultMode)
|
|
@@ -65,18 +256,24 @@ export function useChat(options: UseChatOptions = {}) {
|
|
|
65
256
|
const webSearch = ref(true)
|
|
66
257
|
const thinking = ref(true)
|
|
67
258
|
|
|
68
|
-
//
|
|
69
|
-
const
|
|
259
|
+
// ==================== 计算属性 - 当前会话状态 ====================
|
|
260
|
+
const messages = computed(() => {
|
|
261
|
+
if (!currentSessionId.value) return []
|
|
262
|
+
const state = sessionStates.get(currentSessionId.value)
|
|
263
|
+
return state?.messages || []
|
|
264
|
+
})
|
|
70
265
|
|
|
71
|
-
|
|
72
|
-
|
|
266
|
+
const isLoading = computed(() => {
|
|
267
|
+
if (!currentSessionId.value) return false
|
|
268
|
+
const state = sessionStates.get(currentSessionId.value)
|
|
269
|
+
return state?.isLoading || false
|
|
270
|
+
})
|
|
73
271
|
|
|
74
|
-
|
|
272
|
+
// ==================== 会话管理 ====================
|
|
75
273
|
async function loadSessions() {
|
|
76
274
|
try {
|
|
77
275
|
const list = await adapter.getSessions()
|
|
78
276
|
sessions.value = list
|
|
79
|
-
// 如果有会话且没有当前会话,选择最新的
|
|
80
277
|
if (list.length > 0 && !currentSessionId.value) {
|
|
81
278
|
await switchSession(list[0].id)
|
|
82
279
|
}
|
|
@@ -85,57 +282,80 @@ export function useChat(options: UseChatOptions = {}) {
|
|
|
85
282
|
}
|
|
86
283
|
}
|
|
87
284
|
|
|
88
|
-
/** 切换会话 */
|
|
89
285
|
async function switchSession(sessionId: string) {
|
|
90
286
|
if (currentSessionId.value === sessionId) return
|
|
91
287
|
|
|
92
288
|
currentSessionId.value = sessionId
|
|
289
|
+
|
|
290
|
+
// 无状态架构:不需要同步历史到后端
|
|
291
|
+
// 发送消息时会传递历史,后端不维护状态
|
|
93
292
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
messages.value = savedMessages.map(convertToMessage)
|
|
293
|
+
// 获取或创建会话状态
|
|
294
|
+
const state = getSessionState(sessionId)
|
|
97
295
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
296
|
+
// 如果消息为空,从数据库加载
|
|
297
|
+
if (state.messages.length === 0) {
|
|
298
|
+
try {
|
|
299
|
+
const savedMessages = await adapter.getMessages(sessionId)
|
|
300
|
+
state.messages = savedMessages.map(convertToMessage)
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.error('加载消息失败:', error)
|
|
303
|
+
state.messages = []
|
|
103
304
|
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 同步会话配置
|
|
308
|
+
const session = sessions.value.find((s) => s.id === sessionId)
|
|
309
|
+
if (session) {
|
|
310
|
+
mode.value = session.mode
|
|
311
|
+
model.value = session.model
|
|
312
|
+
webSearch.value = session.webSearchEnabled
|
|
313
|
+
thinking.value = session.thinkingEnabled
|
|
107
314
|
}
|
|
108
315
|
}
|
|
109
316
|
|
|
110
|
-
/** 创建新会话 */
|
|
111
317
|
async function createNewSession() {
|
|
112
318
|
try {
|
|
113
319
|
const session = await adapter.createSession({
|
|
114
320
|
title: '新对话',
|
|
115
321
|
model: model.value,
|
|
116
322
|
mode: mode.value,
|
|
323
|
+
webSearchEnabled: webSearch.value,
|
|
324
|
+
thinkingEnabled: thinking.value,
|
|
117
325
|
})
|
|
118
326
|
sessions.value = [session, ...sessions.value]
|
|
327
|
+
|
|
328
|
+
// 创建新会话的状态
|
|
329
|
+
sessionStates.set(session.id, {
|
|
330
|
+
messages: [],
|
|
331
|
+
isLoading: false,
|
|
332
|
+
abortController: null,
|
|
333
|
+
})
|
|
334
|
+
|
|
119
335
|
currentSessionId.value = session.id
|
|
120
|
-
messages.value = []
|
|
121
336
|
} catch (error) {
|
|
122
337
|
console.error('创建会话失败:', error)
|
|
123
338
|
}
|
|
124
339
|
}
|
|
125
340
|
|
|
126
|
-
/** 删除会话 */
|
|
127
341
|
async function deleteSession(sessionId: string) {
|
|
128
342
|
try {
|
|
343
|
+
// 如果该会话正在加载,先取消
|
|
344
|
+
const state = sessionStates.get(sessionId)
|
|
345
|
+
if (state?.isLoading && state.abortController) {
|
|
346
|
+
state.abortController.abort()
|
|
347
|
+
adapter.cancel()
|
|
348
|
+
}
|
|
349
|
+
|
|
129
350
|
await adapter.deleteSession(sessionId)
|
|
130
351
|
sessions.value = sessions.value.filter((s) => s.id !== sessionId)
|
|
352
|
+
sessionStates.delete(sessionId)
|
|
131
353
|
|
|
132
|
-
// 如果删除的是当前会话,切换到下一个
|
|
133
354
|
if (currentSessionId.value === sessionId) {
|
|
134
355
|
if (sessions.value.length > 0) {
|
|
135
356
|
await switchSession(sessions.value[0].id)
|
|
136
357
|
} else {
|
|
137
358
|
currentSessionId.value = null
|
|
138
|
-
messages.value = []
|
|
139
359
|
}
|
|
140
360
|
}
|
|
141
361
|
} catch (error) {
|
|
@@ -143,88 +363,383 @@ export function useChat(options: UseChatOptions = {}) {
|
|
|
143
363
|
}
|
|
144
364
|
}
|
|
145
365
|
|
|
146
|
-
|
|
366
|
+
async function hideSession(sessionId: string, hidden: boolean) {
|
|
367
|
+
try {
|
|
368
|
+
await adapter.updateSession(sessionId, { hidden })
|
|
369
|
+
sessions.value = sessions.value.map((s) =>
|
|
370
|
+
s.id === sessionId ? { ...s, hidden } : s
|
|
371
|
+
)
|
|
372
|
+
} catch (error) {
|
|
373
|
+
console.error('更新会话隐藏状态失败:', error)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function clearAllSessions() {
|
|
378
|
+
try {
|
|
379
|
+
// 取消所有正在进行的请求
|
|
380
|
+
for (const [, state] of sessionStates) {
|
|
381
|
+
if (state.isLoading && state.abortController) {
|
|
382
|
+
state.abortController.abort()
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
adapter.cancel()
|
|
386
|
+
|
|
387
|
+
for (const session of sessions.value) {
|
|
388
|
+
await adapter.deleteSession(session.id)
|
|
389
|
+
}
|
|
390
|
+
sessions.value = []
|
|
391
|
+
sessionStates.clear()
|
|
392
|
+
await createNewSession()
|
|
393
|
+
} catch (error) {
|
|
394
|
+
console.error('清空所有会话失败:', error)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function hideOtherSessions() {
|
|
399
|
+
if (!currentSessionId.value) return
|
|
400
|
+
try {
|
|
401
|
+
for (const session of sessions.value) {
|
|
402
|
+
if (session.id !== currentSessionId.value && !session.hidden) {
|
|
403
|
+
await adapter.updateSession(session.id, { hidden: true })
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
sessions.value = sessions.value.map((s) =>
|
|
407
|
+
s.id === currentSessionId.value ? s : { ...s, hidden: true }
|
|
408
|
+
)
|
|
409
|
+
} catch (error) {
|
|
410
|
+
console.error('隐藏其他会话失败:', error)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function exportCurrentSession(): string | null {
|
|
415
|
+
if (!currentSessionId.value) return null
|
|
416
|
+
const session = sessions.value.find((s) => s.id === currentSessionId.value)
|
|
417
|
+
if (!session) return null
|
|
418
|
+
|
|
419
|
+
const state = sessionStates.get(currentSessionId.value)
|
|
420
|
+
const msgs = state?.messages || []
|
|
421
|
+
|
|
422
|
+
return JSON.stringify({
|
|
423
|
+
session: {
|
|
424
|
+
id: session.id,
|
|
425
|
+
title: session.title,
|
|
426
|
+
model: session.model,
|
|
427
|
+
mode: session.mode,
|
|
428
|
+
createdAt: session.createdAt,
|
|
429
|
+
updatedAt: session.updatedAt,
|
|
430
|
+
},
|
|
431
|
+
messages: msgs.map((msg) => ({
|
|
432
|
+
id: msg.id,
|
|
433
|
+
role: msg.role,
|
|
434
|
+
parts: msg.parts,
|
|
435
|
+
model: msg.model,
|
|
436
|
+
mode: msg.mode,
|
|
437
|
+
timestamp: msg.timestamp,
|
|
438
|
+
})),
|
|
439
|
+
exportedAt: new Date().toISOString(),
|
|
440
|
+
}, null, 2)
|
|
441
|
+
}
|
|
442
|
+
|
|
147
443
|
async function deleteCurrentSession() {
|
|
148
444
|
if (currentSessionId.value) {
|
|
149
445
|
await deleteSession(currentSessionId.value)
|
|
150
446
|
}
|
|
151
447
|
}
|
|
152
448
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
449
|
+
// ==================== 消息更新 ====================
|
|
450
|
+
/**
|
|
451
|
+
* 更新指定会话的消息
|
|
452
|
+
* 注意:这个函数会更新特定 sessionId 的消息,不受当前会话切换影响
|
|
453
|
+
*/
|
|
454
|
+
function updateSessionMessage(sessionId: string, messageIndex: number, event: ChatEvent) {
|
|
455
|
+
const state = sessionStates.get(sessionId)
|
|
456
|
+
if (!state) return
|
|
457
|
+
|
|
458
|
+
const msg = state.messages[messageIndex]
|
|
156
459
|
if (!msg) return
|
|
157
460
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
461
|
+
const updatedMsg = { ...msg }
|
|
462
|
+
let parts = [...updatedMsg.parts]
|
|
463
|
+
|
|
464
|
+
switch (event.type) {
|
|
465
|
+
case 'thinking_start': {
|
|
466
|
+
parts.push({
|
|
467
|
+
type: 'thinking',
|
|
468
|
+
text: '',
|
|
469
|
+
status: 'running',
|
|
470
|
+
})
|
|
471
|
+
break
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
case 'thinking_delta': {
|
|
475
|
+
const data = event.data as { content: string }
|
|
476
|
+
const lastThinkingIndex = parts.findLastIndex(
|
|
477
|
+
p => p.type === 'thinking' && p.status === 'running'
|
|
478
|
+
)
|
|
479
|
+
if (lastThinkingIndex >= 0) {
|
|
480
|
+
const part = parts[lastThinkingIndex] as ThinkingPart
|
|
481
|
+
parts[lastThinkingIndex] = { ...part, text: part.text + data.content }
|
|
482
|
+
} else {
|
|
483
|
+
parts.push({ type: 'thinking', text: data.content, status: 'running' })
|
|
163
484
|
}
|
|
164
|
-
msg.thinkingComplete = thinkingData.isComplete
|
|
165
485
|
break
|
|
166
486
|
}
|
|
167
487
|
|
|
168
|
-
case '
|
|
169
|
-
|
|
488
|
+
case 'thinking_end': {
|
|
489
|
+
const data = event.data as { duration: number }
|
|
490
|
+
const lastThinkingIndex = parts.findLastIndex(
|
|
491
|
+
p => p.type === 'thinking' && p.status === 'running'
|
|
492
|
+
)
|
|
493
|
+
if (lastThinkingIndex >= 0) {
|
|
494
|
+
const part = parts[lastThinkingIndex] as ThinkingPart
|
|
495
|
+
parts[lastThinkingIndex] = {
|
|
496
|
+
...part,
|
|
497
|
+
status: 'done',
|
|
498
|
+
duration: Math.round(data.duration / 1000),
|
|
499
|
+
}
|
|
500
|
+
}
|
|
170
501
|
break
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
case 'search_start': {
|
|
505
|
+
const data = event.data as { query?: string }
|
|
506
|
+
parts.push({ type: 'search', query: data.query, status: 'running' })
|
|
507
|
+
break
|
|
508
|
+
}
|
|
171
509
|
|
|
172
510
|
case 'search_result': {
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
511
|
+
const data = event.data as { results: SearchResult[] }
|
|
512
|
+
const lastSearchIndex = parts.findLastIndex(p => p.type === 'search')
|
|
513
|
+
if (lastSearchIndex >= 0) {
|
|
514
|
+
const part = parts[lastSearchIndex] as SearchPart
|
|
515
|
+
parts[lastSearchIndex] = { ...part, results: data.results, status: 'done' }
|
|
516
|
+
}
|
|
176
517
|
break
|
|
177
518
|
}
|
|
178
519
|
|
|
179
|
-
case '
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
status: '
|
|
186
|
-
}
|
|
520
|
+
case 'search_end': {
|
|
521
|
+
const lastSearchIndex = parts.findLastIndex(
|
|
522
|
+
p => p.type === 'search' && p.status === 'running'
|
|
523
|
+
)
|
|
524
|
+
if (lastSearchIndex >= 0) {
|
|
525
|
+
const part = parts[lastSearchIndex] as SearchPart
|
|
526
|
+
parts[lastSearchIndex] = { ...part, status: 'done' }
|
|
527
|
+
}
|
|
187
528
|
break
|
|
188
529
|
}
|
|
189
530
|
|
|
190
|
-
case '
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
531
|
+
case 'tool_approval_request': {
|
|
532
|
+
const data = event.data as { id: string; name: string; args: Record<string, unknown> }
|
|
533
|
+
|
|
534
|
+
// 如果当前模式是自动执行,直接批准(处理发送消息后切换模式的情况)
|
|
535
|
+
if (autoRunConfig.value.mode === 'run-everything') {
|
|
536
|
+
adapter.respondToolApproval?.(data.id, true)
|
|
537
|
+
// 不创建 pending 状态,等待 tool_call_start 事件
|
|
538
|
+
break
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// manual 模式:创建 pending 状态等待用户确认
|
|
542
|
+
const existingIndex = parts.findIndex(
|
|
543
|
+
p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
|
|
544
|
+
)
|
|
545
|
+
if (existingIndex >= 0) {
|
|
546
|
+
// 更新现有 part 为 pending
|
|
547
|
+
const part = parts[existingIndex] as ToolCallPart
|
|
548
|
+
parts[existingIndex] = { ...part, status: 'pending', result: null }
|
|
549
|
+
} else {
|
|
550
|
+
// 创建新的 pending part
|
|
551
|
+
parts.push({
|
|
552
|
+
type: 'tool_call',
|
|
553
|
+
id: data.id,
|
|
554
|
+
name: data.name,
|
|
555
|
+
args: data.args,
|
|
556
|
+
status: 'pending',
|
|
557
|
+
result: null,
|
|
198
558
|
})
|
|
199
559
|
}
|
|
200
560
|
break
|
|
201
561
|
}
|
|
202
562
|
|
|
203
|
-
case '
|
|
204
|
-
|
|
563
|
+
case 'tool_call_start': {
|
|
564
|
+
const data = event.data as { id: string; name: string; args: Record<string, unknown> }
|
|
565
|
+
// 检查是否已存在 pending 状态的 part
|
|
566
|
+
const existingIndex = parts.findIndex(
|
|
567
|
+
p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
|
|
568
|
+
)
|
|
569
|
+
if (existingIndex >= 0) {
|
|
570
|
+
// 更新现有 part 为 running
|
|
571
|
+
const part = parts[existingIndex] as ToolCallPart
|
|
572
|
+
parts[existingIndex] = { ...part, status: 'running', result: null }
|
|
573
|
+
} else {
|
|
574
|
+
// 创建新的 running part
|
|
575
|
+
parts.push({
|
|
576
|
+
type: 'tool_call',
|
|
577
|
+
id: data.id,
|
|
578
|
+
name: data.name,
|
|
579
|
+
args: data.args,
|
|
580
|
+
status: 'running',
|
|
581
|
+
result: null,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
break
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
case 'tool_call_result': {
|
|
588
|
+
const data = event.data as { id: string; name: string; result: string; success: boolean }
|
|
589
|
+
|
|
590
|
+
let parsedResult: unknown = data.result
|
|
591
|
+
try {
|
|
592
|
+
parsedResult = JSON.parse(data.result)
|
|
593
|
+
} catch {
|
|
594
|
+
// 保持原始字符串
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// 判断状态:检查是否是跳过或取消操作
|
|
598
|
+
const isSkipped = typeof parsedResult === 'object' && parsedResult !== null &&
|
|
599
|
+
'skipped' in parsedResult && (parsedResult as { skipped?: boolean }).skipped === true
|
|
600
|
+
const isCancelled = typeof parsedResult === 'object' && parsedResult !== null &&
|
|
601
|
+
'cancelled' in parsedResult && (parsedResult as { cancelled?: boolean }).cancelled === true
|
|
602
|
+
const status: 'done' | 'error' | 'cancelled' | 'skipped' =
|
|
603
|
+
isSkipped ? 'skipped' : (isCancelled ? 'cancelled' : (data.success ? 'done' : 'error'))
|
|
604
|
+
|
|
605
|
+
// 查找对应的 tool_call
|
|
606
|
+
const toolCallIndex = parts.findIndex(
|
|
607
|
+
p => p.type === 'tool_call' && (p as ToolCallPart).id === data.id
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
if (toolCallIndex >= 0) {
|
|
611
|
+
// 更新 tool_call,添加 result,而不是转换为 tool_result
|
|
612
|
+
const toolCall = parts[toolCallIndex] as ToolCallPart
|
|
613
|
+
parts[toolCallIndex] = {
|
|
614
|
+
...toolCall,
|
|
615
|
+
result: parsedResult,
|
|
616
|
+
status,
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
// 如果没有对应的 tool_call,创建一个新的 tool_call(而不是 tool_result)
|
|
620
|
+
parts.push({
|
|
621
|
+
type: 'tool_call',
|
|
622
|
+
id: data.id,
|
|
623
|
+
name: data.name,
|
|
624
|
+
args: {},
|
|
625
|
+
result: parsedResult,
|
|
626
|
+
status,
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// 移除可能存在的独立 tool_result(如果有的话)
|
|
631
|
+
const existingResultIndex = parts.findIndex(
|
|
632
|
+
p => p.type === 'tool_result' && (p as ToolResultPart).id === data.id
|
|
633
|
+
)
|
|
634
|
+
if (existingResultIndex >= 0) {
|
|
635
|
+
parts.splice(existingResultIndex, 1)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (onToolComplete) {
|
|
639
|
+
// 从事件数据中获取副作用声明
|
|
640
|
+
const sideEffects = (data as { sideEffects?: SideEffect[] }).sideEffects
|
|
641
|
+
onToolComplete({
|
|
642
|
+
name: data.name,
|
|
643
|
+
result: parsedResult,
|
|
644
|
+
sideEffects,
|
|
645
|
+
})
|
|
646
|
+
}
|
|
205
647
|
break
|
|
648
|
+
}
|
|
206
649
|
|
|
207
|
-
case '
|
|
208
|
-
|
|
209
|
-
|
|
650
|
+
case 'text_delta': {
|
|
651
|
+
const data = event.data as { content: string }
|
|
652
|
+
|
|
653
|
+
parts = parts.map(p =>
|
|
654
|
+
p.type === 'search' && p.status === 'running'
|
|
655
|
+
? { ...p, status: 'done' as const }
|
|
656
|
+
: p
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
// 查找最后一个 text part
|
|
660
|
+
const lastTextIndex = parts.findLastIndex(p => p.type === 'text')
|
|
661
|
+
|
|
662
|
+
// 检查最后一个 part 是否是已完成的工具调用/搜索/思考
|
|
663
|
+
// 如果是,说明这是新一轮的文本输出,应该创建新的 text part
|
|
664
|
+
const lastPart = parts[parts.length - 1]
|
|
665
|
+
const shouldCreateNew = lastPart && (
|
|
666
|
+
(lastPart.type === 'tool_call' && ['done', 'error', 'skipped', 'cancelled'].includes((lastPart as ToolCallPart).status)) ||
|
|
667
|
+
(lastPart.type === 'search' && (lastPart as SearchPart).status === 'done') ||
|
|
668
|
+
(lastPart.type === 'thinking' && (lastPart as ThinkingPart).status === 'done')
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
if (shouldCreateNew || lastTextIndex < 0) {
|
|
672
|
+
// 创建新的 text part
|
|
673
|
+
parts.push({ type: 'text', text: data.content })
|
|
674
|
+
} else {
|
|
675
|
+
// 追加到现有的 text part
|
|
676
|
+
const part = parts[lastTextIndex] as TextPart
|
|
677
|
+
parts[lastTextIndex] = { ...part, text: part.text + data.content }
|
|
210
678
|
}
|
|
211
679
|
break
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
case 'error': {
|
|
683
|
+
const data = event.data as { category?: string; message: string; retryable?: boolean }
|
|
684
|
+
|
|
685
|
+
parts = parts.map(p => {
|
|
686
|
+
if (p.type === 'thinking' && p.status === 'running') return { ...p, status: 'done' as const }
|
|
687
|
+
if (p.type === 'search' && p.status === 'running') return { ...p, status: 'done' as const }
|
|
688
|
+
if (p.type === 'tool_call' && p.status === 'running') return { ...p, status: 'error' as const }
|
|
689
|
+
return p
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
parts.push({
|
|
693
|
+
type: 'error',
|
|
694
|
+
message: data.message,
|
|
695
|
+
category: data.category,
|
|
696
|
+
retryable: data.retryable,
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
updatedMsg.loading = false
|
|
700
|
+
updatedMsg.error = data
|
|
701
|
+
break
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
case 'abort': {
|
|
705
|
+
parts = parts.map(p => {
|
|
706
|
+
if (p.type === 'thinking' && p.status === 'running') return { ...p, status: 'done' as const }
|
|
707
|
+
if (p.type === 'search' && p.status === 'running') return { ...p, status: 'done' as const }
|
|
708
|
+
if (p.type === 'tool_call' && p.status === 'running') return { ...p, status: 'cancelled' as const }
|
|
709
|
+
return p
|
|
710
|
+
})
|
|
711
|
+
updatedMsg.loading = false
|
|
712
|
+
updatedMsg.aborted = true
|
|
713
|
+
break
|
|
714
|
+
}
|
|
212
715
|
|
|
213
|
-
case '
|
|
214
|
-
|
|
716
|
+
case 'done':
|
|
717
|
+
updatedMsg.loading = false
|
|
215
718
|
break
|
|
216
719
|
}
|
|
217
720
|
|
|
218
|
-
|
|
219
|
-
messages
|
|
721
|
+
updatedMsg.parts = parts
|
|
722
|
+
state.messages = [...state.messages]
|
|
723
|
+
state.messages[messageIndex] = updatedMsg
|
|
220
724
|
}
|
|
221
725
|
|
|
222
|
-
|
|
726
|
+
// ==================== 发送消息 ====================
|
|
223
727
|
async function sendMessage(text: string, images?: string[]) {
|
|
224
|
-
if (!text.trim()
|
|
225
|
-
|
|
226
|
-
//
|
|
728
|
+
if (!text.trim()) return
|
|
729
|
+
|
|
730
|
+
// 获取当前会话 ID
|
|
227
731
|
let sessionId = currentSessionId.value
|
|
732
|
+
|
|
733
|
+
// 检查当前会话是否正在加载
|
|
734
|
+
if (sessionId) {
|
|
735
|
+
const currentState = sessionStates.get(sessionId)
|
|
736
|
+
if (currentState?.isLoading) {
|
|
737
|
+
console.warn('[useChat] 当前会话正在进行中,请等待完成')
|
|
738
|
+
return
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// 如果没有会话,创建新会话
|
|
228
743
|
if (!sessionId) {
|
|
229
744
|
try {
|
|
230
745
|
const session = await adapter.createSession({
|
|
@@ -233,6 +748,11 @@ export function useChat(options: UseChatOptions = {}) {
|
|
|
233
748
|
mode: mode.value,
|
|
234
749
|
})
|
|
235
750
|
sessions.value = [session, ...sessions.value]
|
|
751
|
+
sessionStates.set(session.id, {
|
|
752
|
+
messages: [],
|
|
753
|
+
isLoading: false,
|
|
754
|
+
abortController: null,
|
|
755
|
+
})
|
|
236
756
|
currentSessionId.value = session.id
|
|
237
757
|
sessionId = session.id
|
|
238
758
|
} catch (error) {
|
|
@@ -241,15 +761,18 @@ export function useChat(options: UseChatOptions = {}) {
|
|
|
241
761
|
}
|
|
242
762
|
}
|
|
243
763
|
|
|
764
|
+
// 获取会话状态
|
|
765
|
+
const state = getSessionState(sessionId)
|
|
766
|
+
|
|
244
767
|
// 添加用户消息
|
|
245
768
|
const userMsg: ChatMessage = {
|
|
246
769
|
id: generateId(),
|
|
247
770
|
role: 'user',
|
|
248
|
-
|
|
771
|
+
parts: [{ type: 'text', text }],
|
|
249
772
|
images,
|
|
250
773
|
timestamp: new Date(),
|
|
251
774
|
}
|
|
252
|
-
messages
|
|
775
|
+
state.messages = [...state.messages, userMsg]
|
|
253
776
|
|
|
254
777
|
// 保存用户消息
|
|
255
778
|
try {
|
|
@@ -259,8 +782,7 @@ export function useChat(options: UseChatOptions = {}) {
|
|
|
259
782
|
content: text,
|
|
260
783
|
})
|
|
261
784
|
|
|
262
|
-
|
|
263
|
-
if (messages.value.length === 1) {
|
|
785
|
+
if (state.messages.length === 1) {
|
|
264
786
|
const title = text.slice(0, 20) + (text.length > 20 ? '...' : '')
|
|
265
787
|
await adapter.updateSession(sessionId, { title })
|
|
266
788
|
sessions.value = sessions.value.map((s) =>
|
|
@@ -272,120 +794,212 @@ export function useChat(options: UseChatOptions = {}) {
|
|
|
272
794
|
}
|
|
273
795
|
|
|
274
796
|
// 创建助手消息
|
|
275
|
-
const assistantMsgIndex = messages.
|
|
797
|
+
const assistantMsgIndex = state.messages.length
|
|
798
|
+
const assistantMsgId = generateId() // 保存消息 ID 用于后续更新
|
|
276
799
|
const assistantMsg: ChatMessage = {
|
|
277
|
-
id:
|
|
800
|
+
id: assistantMsgId,
|
|
278
801
|
role: 'assistant',
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
802
|
+
parts: [],
|
|
803
|
+
model: model.value,
|
|
804
|
+
mode: mode.value,
|
|
805
|
+
webSearchEnabled: webSearch.value,
|
|
806
|
+
thinkingEnabled: thinking.value,
|
|
283
807
|
loading: true,
|
|
284
808
|
timestamp: new Date(),
|
|
285
809
|
}
|
|
286
|
-
messages
|
|
810
|
+
state.messages = [...state.messages, assistantMsg]
|
|
287
811
|
|
|
288
|
-
|
|
289
|
-
|
|
812
|
+
// 创建本次请求专用的 abortController(避免多请求竞态条件)
|
|
813
|
+
const requestAbortController = new AbortController()
|
|
814
|
+
|
|
815
|
+
// 设置加载状态
|
|
816
|
+
state.isLoading = true
|
|
817
|
+
state.abortController = requestAbortController
|
|
818
|
+
|
|
819
|
+
// 保存当前配置(发送时的快照)
|
|
820
|
+
const sendModel = model.value
|
|
821
|
+
const sendMode = mode.value
|
|
822
|
+
const sendWebSearch = webSearch.value
|
|
823
|
+
const sendThinking = thinking.value
|
|
290
824
|
|
|
825
|
+
// 【关键】立即保存助手消息到数据库(初始状态)
|
|
826
|
+
// 这样即使后续取消或崩溃,消息也已存在
|
|
291
827
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
828
|
+
await adapter.saveMessage({
|
|
829
|
+
id: assistantMsgId, // 使用预生成的 ID
|
|
830
|
+
sessionId,
|
|
831
|
+
role: 'assistant',
|
|
832
|
+
content: '',
|
|
833
|
+
model: sendModel,
|
|
834
|
+
mode: sendMode,
|
|
835
|
+
webSearchEnabled: sendWebSearch,
|
|
836
|
+
thinkingEnabled: sendThinking,
|
|
837
|
+
steps: '[]', // 空数组
|
|
838
|
+
})
|
|
839
|
+
} catch (error) {
|
|
840
|
+
console.error('创建助手消息失败:', error)
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// 增量保存函数:每个重要事件后更新数据库
|
|
844
|
+
const saveMessageProgress = async () => {
|
|
845
|
+
const msg = state.messages[assistantMsgIndex]
|
|
846
|
+
if (!msg) return
|
|
847
|
+
try {
|
|
848
|
+
await adapter.updateMessage?.({
|
|
849
|
+
id: assistantMsgId,
|
|
850
|
+
content: extractTextContent(msg.parts),
|
|
851
|
+
steps: JSON.stringify(msg.parts),
|
|
852
|
+
})
|
|
853
|
+
} catch (error) {
|
|
854
|
+
console.error('更新消息失败:', error)
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
try {
|
|
859
|
+
// 创建纯对象副本,避免 Vue 响应式代理导致序列化问题
|
|
860
|
+
const cleanAutoRunConfig: AutoRunConfig = { ...autoRunConfig.value }
|
|
861
|
+
|
|
862
|
+
// 构建历史消息(不包括刚添加的用户消息和助手占位消息)
|
|
863
|
+
// state.messages 已经包含了用户消息和助手占位消息,所以取到倒数第二个
|
|
864
|
+
const history = state.messages.slice(0, -2).map(msg => ({
|
|
865
|
+
role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
|
|
866
|
+
content: extractTextContent(msg.parts),
|
|
867
|
+
}))
|
|
868
|
+
|
|
869
|
+
for await (const event of adapter.sendMessage(
|
|
294
870
|
text,
|
|
295
871
|
{
|
|
296
|
-
mode:
|
|
297
|
-
model:
|
|
298
|
-
enableWebSearch:
|
|
299
|
-
thinkingMode:
|
|
872
|
+
mode: sendMode,
|
|
873
|
+
model: sendModel,
|
|
874
|
+
enableWebSearch: sendWebSearch,
|
|
875
|
+
thinkingMode: sendThinking ? 'enabled' : 'disabled',
|
|
876
|
+
autoRunConfig: cleanAutoRunConfig,
|
|
877
|
+
history, // 传递历史消息
|
|
300
878
|
},
|
|
301
|
-
images
|
|
879
|
+
images,
|
|
880
|
+
sessionId // 传递 sessionId 用于事件过滤
|
|
302
881
|
)) {
|
|
303
|
-
//
|
|
304
|
-
if (
|
|
882
|
+
// 使用本次请求专用的 abortController 检查(避免被新请求覆盖)
|
|
883
|
+
if (requestAbortController.signal.aborted) break
|
|
305
884
|
|
|
306
|
-
|
|
885
|
+
// 更新消息(直接更新该会话的状态,不受当前会话切换影响)
|
|
886
|
+
updateSessionMessage(sessionId, assistantMsgIndex, event)
|
|
307
887
|
|
|
308
|
-
|
|
888
|
+
// 【关键】在重要事件后增量保存到数据库
|
|
889
|
+
// thinking_end: 思考完成
|
|
890
|
+
// tool_call_result: 工具调用结果返回
|
|
891
|
+
// text_delta: 有新的文本内容
|
|
892
|
+
// done/error/abort: 请求结束
|
|
893
|
+
const shouldSave =
|
|
894
|
+
event.type === 'thinking_end' ||
|
|
895
|
+
event.type === 'tool_call_result' ||
|
|
896
|
+
event.type === 'text_delta' ||
|
|
897
|
+
event.type === 'done' ||
|
|
898
|
+
event.type === 'error' ||
|
|
899
|
+
event.type === 'abort'
|
|
900
|
+
|
|
901
|
+
if (shouldSave) {
|
|
902
|
+
await saveMessageProgress()
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (event.type === 'done' || event.type === 'error') {
|
|
309
906
|
break
|
|
310
907
|
}
|
|
311
908
|
}
|
|
312
909
|
} catch (error) {
|
|
313
910
|
console.error('发送消息失败:', error)
|
|
314
|
-
|
|
911
|
+
updateSessionMessage(sessionId, assistantMsgIndex, {
|
|
315
912
|
type: 'error',
|
|
316
|
-
data: error instanceof Error ? error.message : String(error),
|
|
913
|
+
data: { message: error instanceof Error ? error.message : String(error) },
|
|
317
914
|
})
|
|
318
915
|
} finally {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const finalMsg = messages
|
|
916
|
+
// 更新最终状态
|
|
917
|
+
state.isLoading = false
|
|
918
|
+
|
|
919
|
+
const finalMsg = state.messages[assistantMsgIndex]
|
|
323
920
|
if (finalMsg) {
|
|
324
|
-
|
|
325
|
-
messages
|
|
326
|
-
|
|
327
|
-
// 保存助手消息
|
|
328
|
-
if (sessionId) {
|
|
329
|
-
adapter.saveMessage({
|
|
330
|
-
sessionId,
|
|
331
|
-
role: 'assistant',
|
|
332
|
-
content: finalMsg.content,
|
|
333
|
-
thinking: finalMsg.thinking,
|
|
334
|
-
toolCalls: finalMsg.toolCalls ? JSON.stringify(finalMsg.toolCalls) : undefined,
|
|
335
|
-
searchResults: finalMsg.searchResults
|
|
336
|
-
? JSON.stringify(finalMsg.searchResults)
|
|
337
|
-
: undefined,
|
|
338
|
-
}).catch((e: Error) => console.error('保存助手消息失败:', e))
|
|
339
|
-
}
|
|
921
|
+
state.messages = [...state.messages]
|
|
922
|
+
state.messages[assistantMsgIndex] = { ...finalMsg, loading: false }
|
|
340
923
|
}
|
|
341
924
|
|
|
342
|
-
|
|
925
|
+
// 【关键】最终保存一次,确保所有内容都被持久化
|
|
926
|
+
await saveMessageProgress()
|
|
927
|
+
|
|
928
|
+
state.abortController = null
|
|
343
929
|
}
|
|
344
930
|
}
|
|
345
931
|
|
|
346
|
-
|
|
932
|
+
// ==================== 其他方法 ====================
|
|
347
933
|
function cancelRequest() {
|
|
934
|
+
if (!currentSessionId.value) return
|
|
935
|
+
const state = sessionStates.get(currentSessionId.value)
|
|
936
|
+
if (state) {
|
|
937
|
+
state.abortController?.abort()
|
|
938
|
+
state.isLoading = false
|
|
939
|
+
}
|
|
348
940
|
adapter.cancel()
|
|
349
|
-
abortController.value?.abort()
|
|
350
|
-
isLoading.value = false
|
|
351
941
|
}
|
|
352
942
|
|
|
353
|
-
/** 复制消息 */
|
|
354
943
|
async function copyMessage(messageId: string) {
|
|
355
|
-
|
|
944
|
+
if (!currentSessionId.value) return
|
|
945
|
+
const state = sessionStates.get(currentSessionId.value)
|
|
946
|
+
if (!state) return
|
|
947
|
+
|
|
948
|
+
const msg = state.messages.find((m) => m.id === messageId)
|
|
356
949
|
if (!msg) return
|
|
357
950
|
|
|
358
951
|
try {
|
|
359
|
-
|
|
360
|
-
|
|
952
|
+
const textContent = extractTextContent(msg.parts)
|
|
953
|
+
await navigator.clipboard.writeText(textContent)
|
|
954
|
+
|
|
955
|
+
state.messages = state.messages.map((m) =>
|
|
361
956
|
m.id === messageId ? { ...m, copied: true } : m
|
|
362
957
|
)
|
|
363
958
|
setTimeout(() => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
959
|
+
const s = sessionStates.get(currentSessionId.value!)
|
|
960
|
+
if (s) {
|
|
961
|
+
s.messages = s.messages.map((m) =>
|
|
962
|
+
m.id === messageId ? { ...m, copied: false } : m
|
|
963
|
+
)
|
|
964
|
+
}
|
|
367
965
|
}, 2000)
|
|
368
966
|
} catch (err) {
|
|
369
967
|
console.error('复制失败:', err)
|
|
370
968
|
}
|
|
371
969
|
}
|
|
372
970
|
|
|
373
|
-
/** 重新生成消息 */
|
|
374
971
|
function regenerateMessage(messageIndex: number) {
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
972
|
+
if (!currentSessionId.value) return
|
|
973
|
+
const state = sessionStates.get(currentSessionId.value)
|
|
974
|
+
if (!state) return
|
|
975
|
+
|
|
976
|
+
if (messageIndex > 0 && state.messages[messageIndex - 1]?.role === 'user') {
|
|
977
|
+
const userMsg = state.messages[messageIndex - 1]
|
|
978
|
+
const userText = extractTextContent(userMsg.parts)
|
|
979
|
+
state.messages = state.messages.slice(0, messageIndex - 1)
|
|
980
|
+
sendMessage(userText, userMsg.images)
|
|
379
981
|
}
|
|
380
982
|
}
|
|
381
983
|
|
|
382
|
-
/**
|
|
984
|
+
/** 从指定索引重新发送消息(编辑后重发) */
|
|
985
|
+
function resendFromIndex(index: number, text: string) {
|
|
986
|
+
if (!currentSessionId.value) return
|
|
987
|
+
const state = sessionStates.get(currentSessionId.value)
|
|
988
|
+
if (!state) return
|
|
989
|
+
|
|
990
|
+
// 删除当前消息及后续消息
|
|
991
|
+
state.messages = state.messages.slice(0, index)
|
|
992
|
+
// 发送新消息
|
|
993
|
+
sendMessage(text)
|
|
994
|
+
}
|
|
995
|
+
|
|
383
996
|
function setWorkingDirectory(dir: string) {
|
|
384
|
-
if (adapter.
|
|
385
|
-
adapter.
|
|
997
|
+
if (adapter.setCwd) {
|
|
998
|
+
adapter.setCwd(dir)
|
|
386
999
|
}
|
|
387
1000
|
}
|
|
388
1001
|
|
|
1002
|
+
// ==================== 返回 ====================
|
|
389
1003
|
return {
|
|
390
1004
|
// 状态
|
|
391
1005
|
sessions,
|
|
@@ -403,20 +1017,58 @@ export function useChat(options: UseChatOptions = {}) {
|
|
|
403
1017
|
createNewSession,
|
|
404
1018
|
deleteSession,
|
|
405
1019
|
deleteCurrentSession,
|
|
1020
|
+
hideSession,
|
|
1021
|
+
clearAllSessions,
|
|
1022
|
+
hideOtherSessions,
|
|
1023
|
+
exportCurrentSession,
|
|
406
1024
|
|
|
407
1025
|
// 消息方法
|
|
408
1026
|
sendMessage,
|
|
409
1027
|
cancelRequest,
|
|
410
1028
|
copyMessage,
|
|
411
1029
|
regenerateMessage,
|
|
1030
|
+
resendFromIndex,
|
|
412
1031
|
|
|
413
1032
|
// 配置方法
|
|
414
|
-
setMode: (value: ChatMode) => {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
1033
|
+
setMode: (value: ChatMode) => {
|
|
1034
|
+
mode.value = value
|
|
1035
|
+
if (currentSessionId.value) {
|
|
1036
|
+
adapter.updateSession(currentSessionId.value, { mode: value }).catch((e: Error) =>
|
|
1037
|
+
console.error('更新会话 mode 失败:', e)
|
|
1038
|
+
)
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
setModel: (value: string) => {
|
|
1042
|
+
model.value = value
|
|
1043
|
+
if (currentSessionId.value) {
|
|
1044
|
+
adapter.updateSession(currentSessionId.value, { model: value }).catch((e: Error) =>
|
|
1045
|
+
console.error('更新会话 model 失败:', e)
|
|
1046
|
+
)
|
|
1047
|
+
}
|
|
1048
|
+
},
|
|
1049
|
+
setWebSearch: (value: boolean) => {
|
|
1050
|
+
webSearch.value = value
|
|
1051
|
+
if (currentSessionId.value) {
|
|
1052
|
+
adapter.updateSession(currentSessionId.value, { webSearchEnabled: value }).catch((e: Error) =>
|
|
1053
|
+
console.error('更新会话 webSearchEnabled 失败:', e)
|
|
1054
|
+
)
|
|
1055
|
+
}
|
|
1056
|
+
},
|
|
1057
|
+
setThinking: (value: boolean) => {
|
|
1058
|
+
thinking.value = value
|
|
1059
|
+
if (currentSessionId.value) {
|
|
1060
|
+
adapter.updateSession(currentSessionId.value, { thinkingEnabled: value }).catch((e: Error) =>
|
|
1061
|
+
console.error('更新会话 thinkingEnabled 失败:', e)
|
|
1062
|
+
)
|
|
1063
|
+
}
|
|
1064
|
+
},
|
|
418
1065
|
|
|
419
1066
|
// 工具方法
|
|
420
1067
|
setWorkingDirectory,
|
|
1068
|
+
|
|
1069
|
+
// 自动运行配置
|
|
1070
|
+
autoRunConfig,
|
|
1071
|
+
loadAutoRunConfig,
|
|
1072
|
+
saveAutoRunConfig,
|
|
421
1073
|
}
|
|
422
1074
|
}
|