tabby-ai-assistant 1.0.0
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 +232 -0
- package/dist/components/chat/chat-input.component.d.ts +65 -0
- package/dist/components/chat/chat-interface.component.d.ts +71 -0
- package/dist/components/chat/chat-message.component.d.ts +53 -0
- package/dist/components/chat/chat-settings.component.d.ts +62 -0
- package/dist/components/common/error-message.component.d.ts +11 -0
- package/dist/components/common/loading-spinner.component.d.ts +4 -0
- package/dist/components/security/consent-dialog.component.d.ts +11 -0
- package/dist/components/security/password-prompt.component.d.ts +10 -0
- package/dist/components/security/risk-confirm-dialog.component.d.ts +36 -0
- package/dist/components/settings/ai-settings-tab.component.d.ts +72 -0
- package/dist/components/settings/general-settings.component.d.ts +60 -0
- package/dist/components/settings/provider-config.component.d.ts +182 -0
- package/dist/components/settings/security-settings.component.d.ts +23 -0
- package/dist/components/terminal/ai-toolbar-button.component.d.ts +10 -0
- package/dist/components/terminal/command-preview.component.d.ts +15 -0
- package/dist/components/terminal/command-suggestion.component.d.ts +16 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2 -0
- package/dist/index.js.LICENSE.txt +18 -0
- package/dist/main.d.ts +8 -0
- package/dist/providers/tabby/ai-config.provider.d.ts +18 -0
- package/dist/providers/tabby/ai-hotkey.provider.d.ts +21 -0
- package/dist/providers/tabby/ai-settings-tab.provider.d.ts +11 -0
- package/dist/providers/tabby/ai-toolbar-button.provider.d.ts +17 -0
- package/dist/services/chat/chat-history.service.d.ts +67 -0
- package/dist/services/chat/chat-session.service.d.ts +58 -0
- package/dist/services/chat/command-generator.service.d.ts +49 -0
- package/dist/services/core/ai-assistant.service.d.ts +88 -0
- package/dist/services/core/ai-provider-manager.service.d.ts +119 -0
- package/dist/services/core/config-provider.service.d.ts +137 -0
- package/dist/services/core/logger.service.d.ts +21 -0
- package/dist/services/providers/anthropic-provider.service.d.ts +39 -0
- package/dist/services/providers/base-provider.service.d.ts +137 -0
- package/dist/services/providers/glm-provider.service.d.ts +91 -0
- package/dist/services/providers/minimax-provider.service.d.ts +93 -0
- package/dist/services/providers/openai-compatible.service.d.ts +39 -0
- package/dist/services/providers/openai-provider.service.d.ts +38 -0
- package/dist/services/security/consent-manager.service.d.ts +65 -0
- package/dist/services/security/password-manager.service.d.ts +67 -0
- package/dist/services/security/risk-assessment.service.d.ts +65 -0
- package/dist/services/security/security-validator.service.d.ts +36 -0
- package/dist/services/terminal/command-analyzer.service.d.ts +20 -0
- package/dist/services/terminal/context-menu.service.d.ts +24 -0
- package/dist/services/terminal/hotkey.service.d.ts +28 -0
- package/dist/services/terminal/terminal-context.service.d.ts +100 -0
- package/dist/types/ai.types.d.ts +107 -0
- package/dist/types/provider.types.d.ts +105 -0
- package/dist/types/security.types.d.ts +85 -0
- package/dist/types/terminal.types.d.ts +150 -0
- package/dist/utils/encryption.utils.d.ts +83 -0
- package/dist/utils/formatting.utils.d.ts +106 -0
- package/dist/utils/validation.utils.d.ts +83 -0
- package/integration-test-output.txt +50 -0
- package/integration-tests/api-integration.test.ts +183 -0
- package/jest.config.js +47 -0
- package/package.json +73 -0
- package/setup-jest.ts +37 -0
- package/src/components/chat/chat-input.component.html +61 -0
- package/src/components/chat/chat-input.component.scss +183 -0
- package/src/components/chat/chat-input.component.ts +149 -0
- package/src/components/chat/chat-interface.component.html +119 -0
- package/src/components/chat/chat-interface.component.scss +354 -0
- package/src/components/chat/chat-interface.component.ts +224 -0
- package/src/components/chat/chat-message.component.html +65 -0
- package/src/components/chat/chat-message.component.scss +178 -0
- package/src/components/chat/chat-message.component.ts +93 -0
- package/src/components/chat/chat-settings.component.html +132 -0
- package/src/components/chat/chat-settings.component.scss +172 -0
- package/src/components/chat/chat-settings.component.ts +168 -0
- package/src/components/common/error-message.component.ts +124 -0
- package/src/components/common/loading-spinner.component.ts +72 -0
- package/src/components/security/consent-dialog.component.ts +77 -0
- package/src/components/security/password-prompt.component.ts +79 -0
- package/src/components/security/risk-confirm-dialog.component.html +87 -0
- package/src/components/security/risk-confirm-dialog.component.scss +360 -0
- package/src/components/security/risk-confirm-dialog.component.ts +96 -0
- package/src/components/settings/ai-settings-tab.component.html +140 -0
- package/src/components/settings/ai-settings-tab.component.scss +371 -0
- package/src/components/settings/ai-settings-tab.component.ts +193 -0
- package/src/components/settings/general-settings.component.html +103 -0
- package/src/components/settings/general-settings.component.scss +285 -0
- package/src/components/settings/general-settings.component.ts +123 -0
- package/src/components/settings/provider-config.component.html +95 -0
- package/src/components/settings/provider-config.component.scss +60 -0
- package/src/components/settings/provider-config.component.ts +206 -0
- package/src/components/settings/security-settings.component.html +51 -0
- package/src/components/settings/security-settings.component.scss +66 -0
- package/src/components/settings/security-settings.component.ts +71 -0
- package/src/components/terminal/ai-toolbar-button.component.ts +49 -0
- package/src/components/terminal/command-preview.component.ts +185 -0
- package/src/components/terminal/command-suggestion.component.ts +128 -0
- package/src/index.ts +163 -0
- package/src/main.ts +16 -0
- package/src/providers/tabby/ai-config.provider.ts +70 -0
- package/src/providers/tabby/ai-hotkey.provider.ts +55 -0
- package/src/providers/tabby/ai-settings-tab.provider.ts +18 -0
- package/src/providers/tabby/ai-toolbar-button.provider.ts +49 -0
- package/src/services/chat/chat-history.service.ts +239 -0
- package/src/services/chat/chat-session.service.spec.ts +249 -0
- package/src/services/chat/chat-session.service.ts +180 -0
- package/src/services/chat/command-generator.service.ts +301 -0
- package/src/services/core/ai-assistant.service.ts +334 -0
- package/src/services/core/ai-provider-manager.service.ts +314 -0
- package/src/services/core/config-provider.service.ts +347 -0
- package/src/services/core/logger.service.ts +104 -0
- package/src/services/providers/anthropic-provider.service.ts +373 -0
- package/src/services/providers/base-provider.service.ts +369 -0
- package/src/services/providers/glm-provider.service.ts +467 -0
- package/src/services/providers/minimax-provider.service.ts +427 -0
- package/src/services/providers/openai-compatible.service.ts +394 -0
- package/src/services/providers/openai-provider.service.ts +376 -0
- package/src/services/security/consent-manager.service.ts +332 -0
- package/src/services/security/password-manager.service.ts +188 -0
- package/src/services/security/risk-assessment.service.ts +340 -0
- package/src/services/security/security-validator.service.ts +143 -0
- package/src/services/terminal/command-analyzer.service.ts +43 -0
- package/src/services/terminal/context-menu.service.ts +45 -0
- package/src/services/terminal/hotkey.service.ts +53 -0
- package/src/services/terminal/terminal-context.service.ts +317 -0
- package/src/styles/ai-assistant.scss +449 -0
- package/src/types/ai.types.ts +133 -0
- package/src/types/provider.types.ts +147 -0
- package/src/types/security.types.ts +103 -0
- package/src/types/terminal.types.ts +186 -0
- package/src/utils/encryption.utils.spec.ts +250 -0
- package/src/utils/encryption.utils.ts +271 -0
- package/src/utils/formatting.utils.ts +359 -0
- package/src/utils/validation.utils.spec.ts +225 -0
- package/src/utils/validation.utils.ts +314 -0
- package/tsconfig.json +45 -0
- package/webpack-docker.config.js +42 -0
- package/webpack.config.js +59 -0
- package/webpack.config.js.backup +57 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { BehaviorSubject, Observable } from 'rxjs';
|
|
3
|
+
import { ChatMessage } from '../../types/ai.types';
|
|
4
|
+
import { LoggerService } from '../core/logger.service';
|
|
5
|
+
|
|
6
|
+
export interface SavedSession {
|
|
7
|
+
sessionId: string;
|
|
8
|
+
title: string;
|
|
9
|
+
messages: ChatMessage[];
|
|
10
|
+
createdAt: Date;
|
|
11
|
+
updatedAt: Date;
|
|
12
|
+
messageCount: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STORAGE_KEY = 'tabby-ai-assistant-chat-history';
|
|
16
|
+
const MAX_SESSIONS = 50;
|
|
17
|
+
const MAX_MESSAGES_PER_SESSION = 1000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 聊天历史服务
|
|
21
|
+
* 持久化存储和管理聊天会话历史
|
|
22
|
+
*/
|
|
23
|
+
@Injectable({
|
|
24
|
+
providedIn: 'root'
|
|
25
|
+
})
|
|
26
|
+
export class ChatHistoryService {
|
|
27
|
+
private sessionsSubject = new BehaviorSubject<SavedSession[]>([]);
|
|
28
|
+
public sessions$ = this.sessionsSubject.asObservable();
|
|
29
|
+
|
|
30
|
+
constructor(private logger: LoggerService) {
|
|
31
|
+
this.loadSessions();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 保存会话
|
|
36
|
+
*/
|
|
37
|
+
saveSession(sessionId: string, messages: ChatMessage[], title?: string): void {
|
|
38
|
+
try {
|
|
39
|
+
const sessions = this.sessionsSubject.value;
|
|
40
|
+
const existingIndex = sessions.findIndex(s => s.sessionId === sessionId);
|
|
41
|
+
|
|
42
|
+
const sessionTitle = title || this.generateSessionTitle(messages);
|
|
43
|
+
const now = new Date();
|
|
44
|
+
|
|
45
|
+
const session: SavedSession = {
|
|
46
|
+
sessionId,
|
|
47
|
+
title: sessionTitle,
|
|
48
|
+
messages: this.trimMessages(messages),
|
|
49
|
+
createdAt: existingIndex >= 0 ? sessions[existingIndex].createdAt : now,
|
|
50
|
+
updatedAt: now,
|
|
51
|
+
messageCount: messages.length
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (existingIndex >= 0) {
|
|
55
|
+
sessions[existingIndex] = session;
|
|
56
|
+
} else {
|
|
57
|
+
sessions.unshift(session);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 限制会话数量
|
|
61
|
+
const trimmedSessions = sessions.slice(0, MAX_SESSIONS);
|
|
62
|
+
this.sessionsSubject.next(trimmedSessions);
|
|
63
|
+
this.saveToStorage(trimmedSessions);
|
|
64
|
+
|
|
65
|
+
this.logger.info('Session saved', { sessionId, title: sessionTitle });
|
|
66
|
+
|
|
67
|
+
} catch (error) {
|
|
68
|
+
this.logger.error('Failed to save session', { error, sessionId });
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 加载会话
|
|
75
|
+
*/
|
|
76
|
+
loadSession(sessionId: string): SavedSession | undefined {
|
|
77
|
+
const sessions = this.sessionsSubject.value;
|
|
78
|
+
return sessions.find(s => s.sessionId === sessionId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 删除会话
|
|
83
|
+
*/
|
|
84
|
+
deleteSession(sessionId: string): void {
|
|
85
|
+
const sessions = this.sessionsSubject.value;
|
|
86
|
+
const filteredSessions = sessions.filter(s => s.sessionId !== sessionId);
|
|
87
|
+
this.sessionsSubject.next(filteredSessions);
|
|
88
|
+
this.saveToStorage(filteredSessions);
|
|
89
|
+
this.logger.info('Session deleted', { sessionId });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 清空所有历史
|
|
94
|
+
*/
|
|
95
|
+
clearAllHistory(): void {
|
|
96
|
+
this.sessionsSubject.next([]);
|
|
97
|
+
this.saveToStorage([]);
|
|
98
|
+
this.logger.info('All chat history cleared');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 搜索会话
|
|
103
|
+
*/
|
|
104
|
+
searchSessions(query: string): SavedSession[] {
|
|
105
|
+
const sessions = this.sessionsSubject.value;
|
|
106
|
+
const lowercaseQuery = query.toLowerCase();
|
|
107
|
+
|
|
108
|
+
return sessions.filter(session =>
|
|
109
|
+
session.title.toLowerCase().includes(lowercaseQuery) ||
|
|
110
|
+
session.messages.some(msg =>
|
|
111
|
+
msg.content.toLowerCase().includes(lowercaseQuery)
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 获取最近的会话
|
|
118
|
+
*/
|
|
119
|
+
getRecentSessions(count: number = 10): SavedSession[] {
|
|
120
|
+
const sessions = this.sessionsSubject.value;
|
|
121
|
+
return sessions.slice(0, count);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 获取会话统计
|
|
126
|
+
*/
|
|
127
|
+
getStatistics(): {
|
|
128
|
+
totalSessions: number;
|
|
129
|
+
totalMessages: number;
|
|
130
|
+
averageMessagesPerSession: number;
|
|
131
|
+
oldestSession?: Date;
|
|
132
|
+
newestSession?: Date;
|
|
133
|
+
} {
|
|
134
|
+
const sessions = this.sessionsSubject.value;
|
|
135
|
+
const totalSessions = sessions.length;
|
|
136
|
+
const totalMessages = sessions.reduce((sum, s) => sum + s.messageCount, 0);
|
|
137
|
+
const averageMessagesPerSession = totalSessions > 0 ? totalMessages / totalSessions : 0;
|
|
138
|
+
|
|
139
|
+
const dates = sessions.map(s => s.createdAt.getTime());
|
|
140
|
+
const oldestSession = dates.length > 0 ? new Date(Math.min(...dates)) : undefined;
|
|
141
|
+
const newestSession = dates.length > 0 ? new Date(Math.max(...dates)) : undefined;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
totalSessions,
|
|
145
|
+
totalMessages,
|
|
146
|
+
averageMessagesPerSession: Math.round(averageMessagesPerSession * 100) / 100,
|
|
147
|
+
oldestSession,
|
|
148
|
+
newestSession
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 导出所有历史
|
|
154
|
+
*/
|
|
155
|
+
exportAllHistory(): string {
|
|
156
|
+
const sessions = this.sessionsSubject.value;
|
|
157
|
+
const exportData = {
|
|
158
|
+
exportDate: new Date().toISOString(),
|
|
159
|
+
version: '1.0',
|
|
160
|
+
sessions
|
|
161
|
+
};
|
|
162
|
+
return JSON.stringify(exportData, null, 2);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 导入历史
|
|
167
|
+
*/
|
|
168
|
+
importHistory(data: string): void {
|
|
169
|
+
try {
|
|
170
|
+
const importData = JSON.parse(data);
|
|
171
|
+
|
|
172
|
+
if (!importData.sessions || !Array.isArray(importData.sessions)) {
|
|
173
|
+
throw new Error('Invalid history format');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const sessions = importData.sessions.map((s: any) => ({
|
|
177
|
+
...s,
|
|
178
|
+
createdAt: new Date(s.createdAt),
|
|
179
|
+
updatedAt: new Date(s.updatedAt)
|
|
180
|
+
}));
|
|
181
|
+
|
|
182
|
+
this.sessionsSubject.next(sessions);
|
|
183
|
+
this.saveToStorage(sessions);
|
|
184
|
+
|
|
185
|
+
this.logger.info('History imported', {
|
|
186
|
+
sessionCount: sessions.length
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
} catch (error) {
|
|
190
|
+
this.logger.error('Failed to import history', error);
|
|
191
|
+
throw new Error('Invalid history file format');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private loadSessions(): void {
|
|
196
|
+
try {
|
|
197
|
+
const stored = localStorage.getItem(STORAGE_KEY);
|
|
198
|
+
if (stored) {
|
|
199
|
+
const sessions = JSON.parse(stored).map((s: any) => ({
|
|
200
|
+
...s,
|
|
201
|
+
createdAt: new Date(s.createdAt),
|
|
202
|
+
updatedAt: new Date(s.updatedAt)
|
|
203
|
+
}));
|
|
204
|
+
this.sessionsSubject.next(sessions);
|
|
205
|
+
this.logger.info('Loaded sessions from storage', { count: sessions.length });
|
|
206
|
+
}
|
|
207
|
+
} catch (error) {
|
|
208
|
+
this.logger.error('Failed to load sessions from storage', error);
|
|
209
|
+
this.sessionsSubject.next([]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private saveToStorage(sessions: SavedSession[]): void {
|
|
214
|
+
try {
|
|
215
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
|
216
|
+
} catch (error) {
|
|
217
|
+
this.logger.error('Failed to save sessions to storage', error);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private generateSessionTitle(messages: ChatMessage[]): string {
|
|
222
|
+
const firstUserMessage = messages.find(m => m.role === 'user');
|
|
223
|
+
if (!firstUserMessage) {
|
|
224
|
+
return `会话 ${new Date().toLocaleString()}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const content = firstUserMessage.content;
|
|
228
|
+
return content.length > 50 ? content.substring(0, 50) + '...' : content;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private trimMessages(messages: ChatMessage[]): ChatMessage[] {
|
|
232
|
+
if (messages.length <= MAX_MESSAGES_PER_SESSION) {
|
|
233
|
+
return messages;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 保留最近的messages
|
|
237
|
+
return messages.slice(-MAX_MESSAGES_PER_SESSION);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { TestBed } from '@angular/core/testing';
|
|
2
|
+
import { ChatSessionService } from './chat-session.service';
|
|
3
|
+
import { AiAssistantService } from '../core/ai-assistant.service';
|
|
4
|
+
import { LoggerService } from '../core/logger.service';
|
|
5
|
+
import { MessageRole } from '../../types/ai.types';
|
|
6
|
+
|
|
7
|
+
// Mock AiAssistantService
|
|
8
|
+
class MockAiAssistantService {
|
|
9
|
+
async chat(request: any): Promise<any> {
|
|
10
|
+
return {
|
|
11
|
+
message: {
|
|
12
|
+
role: MessageRole.ASSISTANT,
|
|
13
|
+
content: 'AI response',
|
|
14
|
+
timestamp: new Date()
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Mock LoggerService
|
|
21
|
+
class MockLoggerService {
|
|
22
|
+
info = jest.fn();
|
|
23
|
+
error = jest.fn();
|
|
24
|
+
warn = jest.fn();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('ChatSessionService', () => {
|
|
28
|
+
let service: ChatSessionService;
|
|
29
|
+
let mockAiService: MockAiAssistantService;
|
|
30
|
+
let mockLogger: MockLoggerService;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
TestBed.configureTestingModule({
|
|
34
|
+
providers: [
|
|
35
|
+
ChatSessionService,
|
|
36
|
+
{ provide: AiAssistantService, useClass: MockAiAssistantService },
|
|
37
|
+
{ provide: LoggerService, useClass: MockLoggerService }
|
|
38
|
+
]
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
service = TestBed.inject(ChatSessionService);
|
|
42
|
+
mockAiService = TestBed.inject(AiAssistantService) as any;
|
|
43
|
+
mockLogger = TestBed.inject(LoggerService) as any;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should be created', () => {
|
|
47
|
+
expect(service).toBeTruthy();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('createSession', () => {
|
|
51
|
+
it('should create a new session', () => {
|
|
52
|
+
const sessionId = service.createSession();
|
|
53
|
+
expect(sessionId).toBeDefined();
|
|
54
|
+
expect(sessionId).toMatch(/^session_/);
|
|
55
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
56
|
+
'Created new chat session',
|
|
57
|
+
expect.objectContaining({ sessionId })
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should initialize with empty messages', () => {
|
|
62
|
+
service.createSession();
|
|
63
|
+
service.messages$.subscribe(messages => {
|
|
64
|
+
expect(messages).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('sendMessage', () => {
|
|
70
|
+
it('should send a message and receive response', async () => {
|
|
71
|
+
service.createSession();
|
|
72
|
+
|
|
73
|
+
await service.sendMessage('Test message');
|
|
74
|
+
|
|
75
|
+
service.messages$.subscribe(messages => {
|
|
76
|
+
expect(messages.length).toBe(2); // User message + AI response
|
|
77
|
+
expect(messages[0].content).toBe('Test message');
|
|
78
|
+
expect(messages[0].role).toBe(MessageRole.USER);
|
|
79
|
+
expect(messages[1].content).toBe('AI response');
|
|
80
|
+
expect(messages[1].role).toBe(MessageRole.ASSISTANT);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should create session if none exists', async () => {
|
|
85
|
+
await service.sendMessage('Test message');
|
|
86
|
+
|
|
87
|
+
const sessionId = service.getCurrentSessionId();
|
|
88
|
+
expect(sessionId).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should handle errors gracefully', async () => {
|
|
92
|
+
mockAiService.chat = jest.fn().mockRejectedValue(new Error('API error'));
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await service.sendMessage('Test message');
|
|
96
|
+
} catch (error) {
|
|
97
|
+
expect(error).toBeDefined();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should set and clear typing indicator', async () => {
|
|
102
|
+
service.createSession();
|
|
103
|
+
|
|
104
|
+
let typingStates: boolean[] = [];
|
|
105
|
+
service.isTyping$.subscribe(state => typingStates.push(state));
|
|
106
|
+
|
|
107
|
+
await service.sendMessage('Test message');
|
|
108
|
+
|
|
109
|
+
// Should be false initially, true during request, false after
|
|
110
|
+
expect(typingStates[typingStates.length - 1]).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('clearSession', () => {
|
|
115
|
+
it('should clear session messages', async () => {
|
|
116
|
+
service.createSession();
|
|
117
|
+
await service.sendMessage('Test message');
|
|
118
|
+
|
|
119
|
+
service.clearSession();
|
|
120
|
+
|
|
121
|
+
service.messages$.subscribe(messages => {
|
|
122
|
+
expect(messages).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('deleteMessage', () => {
|
|
128
|
+
it('should delete a specific message', async () => {
|
|
129
|
+
service.createSession();
|
|
130
|
+
await service.sendMessage('Message 1');
|
|
131
|
+
await service.sendMessage('Message 2');
|
|
132
|
+
|
|
133
|
+
service.messages$.subscribe(messages => {
|
|
134
|
+
expect(messages.length).toBe(4);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const messages = service.getCurrentMessages();
|
|
138
|
+
const messageId = messages[0].id;
|
|
139
|
+
service.deleteMessage(messageId);
|
|
140
|
+
|
|
141
|
+
service.messages$.subscribe(messages => {
|
|
142
|
+
expect(messages.length).toBe(3);
|
|
143
|
+
expect(messages.find(m => m.id === messageId)).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('exportSession', () => {
|
|
149
|
+
it('should export session data', async () => {
|
|
150
|
+
service.createSession();
|
|
151
|
+
await service.sendMessage('Test message');
|
|
152
|
+
|
|
153
|
+
const exported = service.exportSession();
|
|
154
|
+
const data = JSON.parse(exported);
|
|
155
|
+
|
|
156
|
+
expect(data.sessionId).toBeDefined();
|
|
157
|
+
expect(data.messages).toBeDefined();
|
|
158
|
+
expect(data.timestamp).toBeDefined();
|
|
159
|
+
expect(Array.isArray(data.messages)).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('importSession', () => {
|
|
164
|
+
it('should import session data', () => {
|
|
165
|
+
const sessionData = {
|
|
166
|
+
sessionId: 'test-session',
|
|
167
|
+
messages: [
|
|
168
|
+
{
|
|
169
|
+
id: 'msg-1',
|
|
170
|
+
role: MessageRole.USER,
|
|
171
|
+
content: 'Test',
|
|
172
|
+
timestamp: new Date()
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
timestamp: new Date().toISOString()
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
service.importSession(JSON.stringify(sessionData));
|
|
179
|
+
|
|
180
|
+
expect(service.getCurrentSessionId()).toBe('test-session');
|
|
181
|
+
service.messages$.subscribe(messages => {
|
|
182
|
+
expect(messages.length).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should throw error for invalid data', () => {
|
|
187
|
+
expect(() => service.importSession('invalid json')).toThrow();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('switchToSession', () => {
|
|
192
|
+
it('should switch to specified session', () => {
|
|
193
|
+
service.createSession();
|
|
194
|
+
const sessionId = service.getCurrentSessionId();
|
|
195
|
+
|
|
196
|
+
service.switchToSession('new-session-id');
|
|
197
|
+
|
|
198
|
+
expect(service.getCurrentSessionId()).toBe('new-session-id');
|
|
199
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
200
|
+
'Switched to session',
|
|
201
|
+
expect.objectContaining({ sessionId: 'new-session-id' })
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('getCurrentMessages', () => {
|
|
207
|
+
it('should return current messages', async () => {
|
|
208
|
+
service.createSession();
|
|
209
|
+
await service.sendMessage('Test message');
|
|
210
|
+
|
|
211
|
+
const messages = service.getCurrentMessages();
|
|
212
|
+
expect(messages.length).toBe(2);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('Observable streams', () => {
|
|
217
|
+
it('should emit messages when changed', async () => {
|
|
218
|
+
service.createSession();
|
|
219
|
+
|
|
220
|
+
let emittedMessages: any[][] = [];
|
|
221
|
+
service.messages$.subscribe(messages => {
|
|
222
|
+
emittedMessages.push(messages);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
await service.sendMessage('Message 1');
|
|
226
|
+
|
|
227
|
+
expect(emittedMessages.length).toBeGreaterThan(1);
|
|
228
|
+
expect(emittedMessages[emittedMessages.length - 1].length).toBe(2);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should emit errors', async () => {
|
|
232
|
+
service.createSession();
|
|
233
|
+
mockAiService.chat = jest.fn().mockRejectedValue(new Error('Test error'));
|
|
234
|
+
|
|
235
|
+
let errorMessage: string | undefined;
|
|
236
|
+
service.error$.subscribe(error => {
|
|
237
|
+
errorMessage = error;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
await service.sendMessage('Test');
|
|
242
|
+
} catch {
|
|
243
|
+
// Ignore
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
expect(errorMessage).toBe('Test error');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { BehaviorSubject, Observable, Subject } from 'rxjs';
|
|
3
|
+
import { ChatMessage, ChatRequest, ChatResponse, MessageRole } from '../../types/ai.types';
|
|
4
|
+
import { AiAssistantService } from '../core/ai-assistant.service';
|
|
5
|
+
import { LoggerService } from '../core/logger.service';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 聊天会话服务
|
|
9
|
+
* 管理聊天会话的生命周期、消息历史和状态
|
|
10
|
+
*/
|
|
11
|
+
@Injectable({
|
|
12
|
+
providedIn: 'root'
|
|
13
|
+
})
|
|
14
|
+
export class ChatSessionService {
|
|
15
|
+
private currentSessionId: string | null = null;
|
|
16
|
+
private messagesSubject = new BehaviorSubject<ChatMessage[]>([]);
|
|
17
|
+
private isTypingSubject = new BehaviorSubject<boolean>(false);
|
|
18
|
+
private errorSubject = new Subject<string>();
|
|
19
|
+
|
|
20
|
+
public messages$ = this.messagesSubject.asObservable();
|
|
21
|
+
public isTyping$ = this.isTypingSubject.asObservable();
|
|
22
|
+
public error$ = this.errorSubject.asObservable();
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
private aiService: AiAssistantService,
|
|
26
|
+
private logger: LoggerService
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 创建新会话
|
|
31
|
+
*/
|
|
32
|
+
createSession(): string {
|
|
33
|
+
this.currentSessionId = this.generateSessionId();
|
|
34
|
+
this.messagesSubject.next([]);
|
|
35
|
+
this.logger.info('Created new chat session', { sessionId: this.currentSessionId });
|
|
36
|
+
return this.currentSessionId;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 切换到指定会话
|
|
41
|
+
*/
|
|
42
|
+
switchToSession(sessionId: string): void {
|
|
43
|
+
this.currentSessionId = sessionId;
|
|
44
|
+
// TODO: 从存储中加载会话历史
|
|
45
|
+
this.logger.info('Switched to session', { sessionId });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 发送消息
|
|
50
|
+
*/
|
|
51
|
+
async sendMessage(content: string, systemPrompt?: string): Promise<void> {
|
|
52
|
+
if (!this.currentSessionId) {
|
|
53
|
+
this.createSession();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// 添加用户消息
|
|
58
|
+
const userMessage: ChatMessage = {
|
|
59
|
+
id: this.generateMessageId(),
|
|
60
|
+
role: MessageRole.USER,
|
|
61
|
+
content,
|
|
62
|
+
timestamp: new Date()
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const currentMessages = this.messagesSubject.value;
|
|
66
|
+
this.messagesSubject.next([...currentMessages, userMessage]);
|
|
67
|
+
|
|
68
|
+
// 显示打字状态
|
|
69
|
+
this.isTypingSubject.next(true);
|
|
70
|
+
|
|
71
|
+
// 构建请求
|
|
72
|
+
const request: ChatRequest = {
|
|
73
|
+
messages: [...currentMessages, userMessage],
|
|
74
|
+
systemPrompt,
|
|
75
|
+
maxTokens: 2000,
|
|
76
|
+
temperature: 0.7
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// 发送请求
|
|
80
|
+
const response = await this.aiService.chat(request);
|
|
81
|
+
|
|
82
|
+
// 添加AI响应
|
|
83
|
+
const assistantMessage: ChatMessage = {
|
|
84
|
+
...response.message,
|
|
85
|
+
id: this.generateMessageId()
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
this.messagesSubject.next([
|
|
89
|
+
...this.messagesSubject.value,
|
|
90
|
+
assistantMessage
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
this.logger.info('Message sent successfully', {
|
|
94
|
+
sessionId: this.currentSessionId,
|
|
95
|
+
messageId: assistantMessage.id
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
} catch (error) {
|
|
99
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
100
|
+
this.errorSubject.next(errorMessage);
|
|
101
|
+
this.logger.error('Failed to send message', {
|
|
102
|
+
error,
|
|
103
|
+
sessionId: this.currentSessionId,
|
|
104
|
+
content
|
|
105
|
+
});
|
|
106
|
+
throw error;
|
|
107
|
+
} finally {
|
|
108
|
+
this.isTypingSubject.next(false);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 清空会话历史
|
|
114
|
+
*/
|
|
115
|
+
clearSession(): void {
|
|
116
|
+
this.messagesSubject.next([]);
|
|
117
|
+
this.logger.info('Cleared chat session', {
|
|
118
|
+
sessionId: this.currentSessionId
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 删除消息
|
|
124
|
+
*/
|
|
125
|
+
deleteMessage(messageId: string): void {
|
|
126
|
+
const currentMessages = this.messagesSubject.value;
|
|
127
|
+
const filteredMessages = currentMessages.filter(msg => msg.id !== messageId);
|
|
128
|
+
this.messagesSubject.next(filteredMessages);
|
|
129
|
+
this.logger.info('Deleted message', { messageId, sessionId: this.currentSessionId });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 获取当前会话ID
|
|
134
|
+
*/
|
|
135
|
+
getCurrentSessionId(): string | null {
|
|
136
|
+
return this.currentSessionId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 获取当前消息列表
|
|
141
|
+
*/
|
|
142
|
+
getCurrentMessages(): ChatMessage[] {
|
|
143
|
+
return this.messagesSubject.value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 导出会话
|
|
148
|
+
*/
|
|
149
|
+
exportSession(): string {
|
|
150
|
+
const sessionData = {
|
|
151
|
+
sessionId: this.currentSessionId,
|
|
152
|
+
messages: this.messagesSubject.value,
|
|
153
|
+
timestamp: new Date().toISOString()
|
|
154
|
+
};
|
|
155
|
+
return JSON.stringify(sessionData, null, 2);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 导入会话
|
|
160
|
+
*/
|
|
161
|
+
importSession(sessionData: string): void {
|
|
162
|
+
try {
|
|
163
|
+
const data = JSON.parse(sessionData);
|
|
164
|
+
this.currentSessionId = data.sessionId;
|
|
165
|
+
this.messagesSubject.next(data.messages || []);
|
|
166
|
+
this.logger.info('Imported session', { sessionId: this.currentSessionId });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.logger.error('Failed to import session', error);
|
|
169
|
+
throw new Error('Invalid session data format');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private generateSessionId(): string {
|
|
174
|
+
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private generateMessageId(): string {
|
|
178
|
+
return `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
179
|
+
}
|
|
180
|
+
}
|