|
|
@@ -1,34 +1,133 @@
|
|
|
-import { Component, signal } from '@angular/core';
|
|
|
+import { Component, signal, inject, OnDestroy, effect, ElementRef, viewChild } from '@angular/core';
|
|
|
import { Router } from '@angular/router';
|
|
|
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
|
|
-import {
|
|
|
- faBars,
|
|
|
- faTimes,
|
|
|
- faPlus,
|
|
|
- faTrash,
|
|
|
- faMicrophone,
|
|
|
+import { MarkdownComponent } from 'ngx-markdown';
|
|
|
+import {
|
|
|
+ faBars,
|
|
|
+ faTimes,
|
|
|
+ faPlus,
|
|
|
+ faTrash,
|
|
|
+ faMicrophone,
|
|
|
faPaperPlane,
|
|
|
- faExclamationTriangle
|
|
|
+ faExclamationTriangle,
|
|
|
+ faStop,
|
|
|
+ faChevronRight,
|
|
|
+ faCopy,
|
|
|
+ faFont,
|
|
|
} from '@fortawesome/free-solid-svg-icons';
|
|
|
-import { QAMessage } from '../../../core/models/qa.model';
|
|
|
+import { QAMessage, ConversationItem } from '../../../core/models/qa.model';
|
|
|
+import { AiChatService } from '../../../core/services/ai-chat.service';
|
|
|
+import { QaStorageService } from '../../../core/services/qa-storage.service';
|
|
|
+import { ToastService } from '../../../core/services/toast.service';
|
|
|
+
|
|
|
+function generateId(): string {
|
|
|
+ return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
|
+}
|
|
|
+
|
|
|
+const INITIAL_MESSAGES: QAMessage[] = [
|
|
|
+ {
|
|
|
+ id: generateId(),
|
|
|
+ type: 'ai',
|
|
|
+ content: '你好!我是你的AI知识助手。有什么销售问题可以随时问我~',
|
|
|
+ timestamp: Date.now(),
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+function makeTitle(content: string): string {
|
|
|
+ return content.trim().slice(0, 20) || '新对话';
|
|
|
+}
|
|
|
|
|
|
@Component({
|
|
|
selector: 'app-qa',
|
|
|
- imports: [FaIconComponent],
|
|
|
+ imports: [FaIconComponent, MarkdownComponent],
|
|
|
templateUrl: './qa.html',
|
|
|
- styleUrl: './qa.scss'
|
|
|
+ styleUrl: './qa.scss',
|
|
|
})
|
|
|
-export class Qa {
|
|
|
+export class Qa implements OnDestroy {
|
|
|
+ private readonly router = inject(Router);
|
|
|
+ private readonly aiChat = inject(AiChatService);
|
|
|
+ private readonly storage = inject(QaStorageService);
|
|
|
+ private readonly toast = inject(ToastService);
|
|
|
+
|
|
|
readonly showSidebar = signal(false);
|
|
|
readonly showClearModal = signal(false);
|
|
|
+ readonly showThinking = signal(false);
|
|
|
+ readonly isGenerating = signal(false);
|
|
|
|
|
|
- readonly messages = signal<QAMessage[]>([
|
|
|
- { type: 'ai', content: '你好!我是你的AI知识助手。有什么销售问题可以随时问我~', timestamp: Date.now() },
|
|
|
- ]);
|
|
|
+ readonly messages = signal<QAMessage[]>([...INITIAL_MESSAGES]);
|
|
|
+ readonly conversations = signal<ConversationItem[]>([]);
|
|
|
+ readonly currentConversationId = signal<string | null>(null);
|
|
|
+
|
|
|
+ readonly streamingContent = signal('');
|
|
|
+ readonly streamingThoughts = signal('');
|
|
|
+ readonly expandedThoughts = signal<Set<string>>(new Set());
|
|
|
+ readonly contextMenu = signal<{ msgId: string; content: string; x: number; y: number } | null>(null);
|
|
|
+
|
|
|
+ private longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
+ private touchStartPos = { x: 0, y: 0 };
|
|
|
+
|
|
|
+ startLongPress(event: TouchEvent, msgId: string, content: string): void {
|
|
|
+ this.touchStartPos = { x: event.touches[0].clientX, y: event.touches[0].clientY };
|
|
|
+ this.longPressTimer = setTimeout(() => {
|
|
|
+ const el = document.elementFromPoint(this.touchStartPos.x, this.touchStartPos.y);
|
|
|
+ if (el?.closest('.qa-markdown-wrapper')) {
|
|
|
+ this.contextMenu.set({
|
|
|
+ msgId,
|
|
|
+ content,
|
|
|
+ x: Math.min(this.touchStartPos.x, window.innerWidth - 160),
|
|
|
+ y: Math.max(this.touchStartPos.y - 80, 10),
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }, 500);
|
|
|
+ }
|
|
|
+
|
|
|
+ cancelLongPress(): void {
|
|
|
+ if (this.longPressTimer) { clearTimeout(this.longPressTimer); this.longPressTimer = null; }
|
|
|
+ }
|
|
|
+
|
|
|
+ trackTouchMove(event: TouchEvent): void {
|
|
|
+ const dx = Math.abs(event.touches[0].clientX - this.touchStartPos.x);
|
|
|
+ const dy = Math.abs(event.touches[0].clientY - this.touchStartPos.y);
|
|
|
+ if (dx > 10 || dy > 10) this.cancelLongPress();
|
|
|
+ }
|
|
|
+
|
|
|
+ async copyMessageContent(content: string): Promise<void> {
|
|
|
+ this.closeContextMenu();
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(content);
|
|
|
+ this.toast.success('已复制');
|
|
|
+ } catch {
|
|
|
+ this.toast.error('复制失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ enableTextSelection(): void {
|
|
|
+ this.closeContextMenu();
|
|
|
+ }
|
|
|
+
|
|
|
+ closeContextMenu(): void {
|
|
|
+ this.cancelLongPress();
|
|
|
+ this.contextMenu.set(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ toggleThought(msgId: string): void {
|
|
|
+ this.expandedThoughts.update((set) => {
|
|
|
+ const next = new Set(set);
|
|
|
+ if (next.has(msgId)) {
|
|
|
+ next.delete(msgId);
|
|
|
+ } else {
|
|
|
+ next.add(msgId);
|
|
|
+ }
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ isThoughtExpanded(msgId: string): boolean {
|
|
|
+ return this.expandedThoughts().has(msgId);
|
|
|
+ }
|
|
|
+
|
|
|
+ private abortController: AbortController | null = null;
|
|
|
|
|
|
- readonly showThinking = signal(false);
|
|
|
- readonly conversationId = signal('default');
|
|
|
-
|
|
|
readonly faBars = faBars;
|
|
|
readonly faTimes = faTimes;
|
|
|
readonly faPlus = faPlus;
|
|
|
@@ -36,19 +135,83 @@ export class Qa {
|
|
|
readonly faMicrophone = faMicrophone;
|
|
|
readonly faPaperPlane = faPaperPlane;
|
|
|
readonly faExclamationTriangle = faExclamationTriangle;
|
|
|
+ readonly faStop = faStop;
|
|
|
+ readonly faChevronRight = faChevronRight;
|
|
|
+ readonly faCopy = faCopy;
|
|
|
+ readonly faFont = faFont;
|
|
|
+
|
|
|
+ readonly messagesContainer = viewChild<ElementRef<HTMLElement>>('messagesContainer');
|
|
|
|
|
|
- constructor(private readonly router: Router) {}
|
|
|
+ private scrollEffect = effect(() => {
|
|
|
+ if (this.streamingContent() || this.messages().length) {
|
|
|
+ requestAnimationFrame(() => this.scrollToBottom());
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ constructor() {
|
|
|
+ this.loadConversations();
|
|
|
+ }
|
|
|
+
|
|
|
+ ngOnDestroy(): void {
|
|
|
+ this.abortController?.abort();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─── 侧边栏 ────────────────────────────────────────
|
|
|
|
|
|
toggleSidebar(): void {
|
|
|
- this.showSidebar.update(v => !v);
|
|
|
+ if (!this.showSidebar()) {
|
|
|
+ this.loadConversations();
|
|
|
+ }
|
|
|
+ this.showSidebar.update((v) => !v);
|
|
|
}
|
|
|
|
|
|
closeSidebar(): void {
|
|
|
this.showSidebar.set(false);
|
|
|
}
|
|
|
|
|
|
- goBack(): void {
|
|
|
- this.router.navigateByUrl('/');
|
|
|
+ private async loadConversations(): Promise<void> {
|
|
|
+ try {
|
|
|
+ const list = await this.storage.listConversations();
|
|
|
+ this.conversations.set(list);
|
|
|
+ } catch {
|
|
|
+ // 静默失败
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─── 会话操作 ──────────────────────────────────────
|
|
|
+
|
|
|
+ async selectConversation(objectId: string): Promise<void> {
|
|
|
+ if (this.isGenerating()) return;
|
|
|
+ if (objectId === this.currentConversationId()) {
|
|
|
+ this.closeSidebar();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = await this.storage.getConversation(objectId);
|
|
|
+ if (!data) {
|
|
|
+ this.toast.error('会话加载失败');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.abortController?.abort();
|
|
|
+ // 为加载出的历史消息补充 id 字段(兼容性处理)
|
|
|
+ const messagesWithIds: QAMessage[] = data.messages.map((msg) => ({
|
|
|
+ ...msg,
|
|
|
+ id: (msg as any).id || generateId(),
|
|
|
+ }));
|
|
|
+ this.messages.set(messagesWithIds.length > 0 ? messagesWithIds : [...INITIAL_MESSAGES]);
|
|
|
+ this.currentConversationId.set(objectId);
|
|
|
+ this.isGenerating.set(false);
|
|
|
+ this.showThinking.set(false);
|
|
|
+ this.closeSidebar();
|
|
|
+ }
|
|
|
+
|
|
|
+ newConversation(): void {
|
|
|
+ this.abortController?.abort();
|
|
|
+ this.messages.set([...INITIAL_MESSAGES]);
|
|
|
+ this.currentConversationId.set(null);
|
|
|
+ this.isGenerating.set(false);
|
|
|
+ this.showThinking.set(false);
|
|
|
}
|
|
|
|
|
|
openClearModal(): void {
|
|
|
@@ -59,28 +222,215 @@ export class Qa {
|
|
|
this.showClearModal.set(false);
|
|
|
}
|
|
|
|
|
|
- clearConversation(): void {
|
|
|
- this.messages.set([
|
|
|
- { type: 'ai', content: '你好!我是你的AI知识助手。有什么销售问题可以随时问我~', timestamp: Date.now() },
|
|
|
- ]);
|
|
|
+ async clearConversation(): Promise<void> {
|
|
|
this.showClearModal.set(false);
|
|
|
+ const id = this.currentConversationId();
|
|
|
+ if (id) {
|
|
|
+ try {
|
|
|
+ await this.storage.deleteConversation(id);
|
|
|
+ } catch {
|
|
|
+ this.toast.error('删除失败');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.abortController?.abort();
|
|
|
+ this.messages.set([...INITIAL_MESSAGES]);
|
|
|
+ this.currentConversationId.set(null);
|
|
|
+ this.isGenerating.set(false);
|
|
|
+ this.showThinking.set(false);
|
|
|
+ this.loadConversations();
|
|
|
}
|
|
|
|
|
|
- newConversation(): void {
|
|
|
- this.messages.set([
|
|
|
- { type: 'ai', content: '你好!我是你的AI知识助手。有什么销售问题可以随时问我~', timestamp: Date.now() },
|
|
|
- ]);
|
|
|
+ goBack(): void {
|
|
|
+ this.router.navigateByUrl('/home');
|
|
|
}
|
|
|
|
|
|
- sendMessage(content: string): void {
|
|
|
- if (!content.trim()) return;
|
|
|
- this.messages.update(msgs => [...msgs, { type: 'user', content: content.trim(), timestamp: Date.now() }]);
|
|
|
+ // ─── 发送消息 ──────────────────────────────────────
|
|
|
+
|
|
|
+ async sendMessage(content: string): Promise<void> {
|
|
|
+ if (!content.trim() || this.isGenerating()) return;
|
|
|
+
|
|
|
+ const trimmed = content.trim();
|
|
|
+ let conversationId = this.currentConversationId();
|
|
|
+
|
|
|
+ // 新会话:先创建数据库记录
|
|
|
+ if (!conversationId) {
|
|
|
+ try {
|
|
|
+ const title = makeTitle(trimmed);
|
|
|
+ const initialMsgs: QAMessage[] = [
|
|
|
+ ...INITIAL_MESSAGES,
|
|
|
+ { id: generateId(), type: 'user', content: trimmed, timestamp: Date.now() },
|
|
|
+ ];
|
|
|
+ conversationId = await this.storage.createConversation(title, initialMsgs);
|
|
|
+ this.currentConversationId.set(conversationId);
|
|
|
+ this.loadConversations();
|
|
|
+ } catch {
|
|
|
+ this.toast.error('创建会话失败');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 已有会话:添加用户消息
|
|
|
+ this.messages.update((msgs) => [
|
|
|
+ ...msgs,
|
|
|
+ { id: generateId(), type: 'user', content: trimmed, timestamp: Date.now() },
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
this.showThinking.set(true);
|
|
|
- setTimeout(() => {
|
|
|
- const replies = ['好的,关于这个问题,我来详细为你解答...', '这是一个常见的销售场景,让我分享一些应对策略...', '很高兴你问到这个问题。从专业角度看...'];
|
|
|
- const reply = replies[Math.floor(Math.random() * replies.length)];
|
|
|
- this.messages.update(msgs => [...msgs, { type: 'ai', content: reply, timestamp: Date.now() }]);
|
|
|
+ this.isGenerating.set(true);
|
|
|
+ this.streamingContent.set('');
|
|
|
+ this.streamingThoughts.set('');
|
|
|
+
|
|
|
+ this.abortController = new AbortController();
|
|
|
+ let finalContent = '';
|
|
|
+ let finalThoughts = '';
|
|
|
+ let finalImages: string[] = [];
|
|
|
+ let aiMessageInserted = false;
|
|
|
+ let aiMessageId = '';
|
|
|
+ let thoughtsAutoCollapsed = false;
|
|
|
+ let lastContentLen = 0;
|
|
|
+
|
|
|
+ const body: Record<string, unknown> = {
|
|
|
+ prompt: trimmed,
|
|
|
+ parameters: { incremental_output: true, has_thoughts: true },
|
|
|
+ debug: {},
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ for await (const chunk of this.aiChat.stream('/api/chat', body, this.abortController.signal)) {
|
|
|
+ finalContent = chunk.text;
|
|
|
+ finalThoughts = chunk.thoughts;
|
|
|
+ finalImages = chunk.images;
|
|
|
+
|
|
|
+ this.streamingContent.set(finalContent);
|
|
|
+ this.streamingThoughts.set(finalThoughts);
|
|
|
+
|
|
|
+ // 首次有内容时插入 AI 消息
|
|
|
+ if (!aiMessageInserted && (finalContent || finalThoughts)) {
|
|
|
+ aiMessageId = generateId();
|
|
|
+ this.expandedThoughts.update((set) => new Set(set).add(aiMessageId));
|
|
|
+ const aiMsg: QAMessage = {
|
|
|
+ id: aiMessageId,
|
|
|
+ type: 'ai',
|
|
|
+ content: finalContent,
|
|
|
+ thoughts: finalThoughts || undefined,
|
|
|
+ images: finalImages.length > 0 ? [...finalImages] : undefined,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ };
|
|
|
+ this.messages.update((msgs) => [...msgs, aiMsg]);
|
|
|
+ aiMessageInserted = true;
|
|
|
+ this.showThinking.set(false);
|
|
|
+ } else if (aiMessageInserted) {
|
|
|
+ const lastIdx = this.messages().length - 1;
|
|
|
+ this.messages.update((msgs) => {
|
|
|
+ const updated = [...msgs];
|
|
|
+ updated[lastIdx] = {
|
|
|
+ ...updated[lastIdx],
|
|
|
+ content: finalContent,
|
|
|
+ thoughts: finalThoughts || undefined,
|
|
|
+ images: finalImages.length > 0 ? [...finalImages] : undefined,
|
|
|
+ };
|
|
|
+ return updated;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检测到内容开始输出时,自动折叠思考(仅一次)
|
|
|
+ if (!thoughtsAutoCollapsed && finalContent.length > lastContentLen && finalContent.trim().length > 0) {
|
|
|
+ this.expandedThoughts.update((set) => {
|
|
|
+ const next = new Set(set);
|
|
|
+ next.delete(aiMessageId);
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ thoughtsAutoCollapsed = true;
|
|
|
+ }
|
|
|
+ lastContentLen = finalContent.length;
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ if ((err as Error).name === 'AbortError') {
|
|
|
+ if (finalContent || finalThoughts) {
|
|
|
+ if (!aiMessageInserted) {
|
|
|
+ const aiMsg: QAMessage = {
|
|
|
+ id: generateId(),
|
|
|
+ type: 'ai',
|
|
|
+ content: finalContent || '已停止生成',
|
|
|
+ thoughts: finalThoughts || undefined,
|
|
|
+ images: finalImages.length > 0 ? [...finalImages] : undefined,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ };
|
|
|
+ this.messages.update((msgs) => [...msgs, aiMsg]);
|
|
|
+ } else {
|
|
|
+ const lastIdx = this.messages().length - 1;
|
|
|
+ this.messages.update((msgs) => {
|
|
|
+ const updated = [...msgs];
|
|
|
+ updated[lastIdx] = {
|
|
|
+ ...updated[lastIdx],
|
|
|
+ content: finalContent || '已停止生成',
|
|
|
+ thoughts: finalThoughts || undefined,
|
|
|
+ images: finalImages.length > 0 ? [...finalImages] : undefined,
|
|
|
+ };
|
|
|
+ return updated;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.showThinking.set(false);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const errorContent = `❌ 错误: ${(err as Error).message}`;
|
|
|
+ if (!aiMessageInserted) {
|
|
|
+ const aiMsg: QAMessage = {
|
|
|
+ id: generateId(),
|
|
|
+ type: 'ai',
|
|
|
+ content: errorContent,
|
|
|
+ timestamp: Date.now(),
|
|
|
+ };
|
|
|
+ this.messages.update((msgs) => [...msgs, aiMsg]);
|
|
|
+ } else {
|
|
|
+ const lastIdx = this.messages().length - 1;
|
|
|
+ this.messages.update((msgs) => {
|
|
|
+ const updated = [...msgs];
|
|
|
+ updated[lastIdx] = { ...updated[lastIdx], content: errorContent };
|
|
|
+ return updated;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.showThinking.set(false);
|
|
|
+ this.toast.error((err as Error).message || '请求失败');
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ this.abortController = null;
|
|
|
+ this.isGenerating.set(false);
|
|
|
this.showThinking.set(false);
|
|
|
- }, 1500);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 保存到数据库
|
|
|
+ if (conversationId) {
|
|
|
+ try {
|
|
|
+ await this.storage.updateConversation(conversationId, this.messages());
|
|
|
+ } catch {
|
|
|
+ // 静默失败,下次发消息时会再次尝试保存
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ stopGeneration(): void {
|
|
|
+ this.abortController?.abort();
|
|
|
+ }
|
|
|
+
|
|
|
+ handleEnter(input: HTMLInputElement): void {
|
|
|
+ if (this.isGenerating()) {
|
|
|
+ this.stopGeneration();
|
|
|
+ } else {
|
|
|
+ this.handleSend(input);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ handleSend(input: HTMLInputElement): void {
|
|
|
+ const val = input.value;
|
|
|
+ input.value = '';
|
|
|
+ this.sendMessage(val);
|
|
|
+ }
|
|
|
+
|
|
|
+ private scrollToBottom(): void {
|
|
|
+ const el = this.messagesContainer()?.nativeElement;
|
|
|
+ if (el) {
|
|
|
+ el.scrollTop = el.scrollHeight;
|
|
|
+ }
|
|
|
}
|
|
|
}
|