Sfoglia il codice sorgente

feat(ai): integrate DeepSeek chat assistant with config and UI

17846405080 1 mese fa
parent
commit
fe8b1f8848

+ 150 - 92
campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/ai-chat/ai-chat.component.html

@@ -1,109 +1,167 @@
-<!-- AI聊天界面 - 移动端优先设计 -->
-<div class="ai-chat-container">
-  <!-- 聊天头部 -->
-  <div class="chat-header">
-    <div class="chat-title">
-      <h2>健康小助手</h2>
-      <div class="ai-status" [ngClass]="{ 'active': isReceivingResponse }">
-        <span *ngIf="isLoading" class="loading-dots">
-          <span class="dot"></span>
-          <span class="dot"></span>
-          <span class="dot"></span>
-        </span>
-        <span *ngIf="!isLoading">已连接</span>
+<!-- AI聊天界面 - 豆包风格分区布局 -->
+<div class="ai-chat-container" [class.sidebar-collapsed]="sidebarCollapsed()">
+  
+  <!-- 左侧边栏 - 会话列表 -->
+  <div class="chat-sidebar">
+    <!-- 侧边栏头部 -->
+    <div class="sidebar-header">
+      <div class="logo-section">
+        <span class="logo-icon">🏃</span>
+        <span class="logo-text" *ngIf="!sidebarCollapsed()">健康助手</span>
       </div>
-    </div>
-    <div class="header-actions">
-      <button class="action-btn clear-btn" (click)="clearChat()" title="清空聊天记录">
-        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-          <polyline points="3 6 5 6 21 6"></polyline>
-          <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
+      <button class="toggle-btn" (click)="toggleSidebar()" title="收起/展开">
+        <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path *ngIf="!sidebarCollapsed()" d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/>
+          <path *ngIf="sidebarCollapsed()" d="M13 5l7 7-7 7M6 5l7 7-7 7"/>
         </svg>
       </button>
-      <button class="action-btn close-btn" (click)="close.emit()" title="关闭">
-        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-          <line x1="18" y1="6" x2="6" y2="18"></line>
-          <line x1="6" y1="6" x2="18" y2="18"></line>
+    </div>
+
+    <!-- 新建对话按钮 -->
+    <button class="new-chat-btn" (click)="createNewSession()">
+      <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <path d="M12 5v14M5 12h14"/>
+      </svg>
+      <span *ngIf="!sidebarCollapsed()">新对话</span>
+    </button>
+
+    <!-- 历史对话列表 -->
+    <div class="sessions-list" *ngIf="!sidebarCollapsed()">
+      <div class="sessions-title">历史对话</div>
+      <div class="session-items">
+        <div 
+          *ngFor="let session of sessions()" 
+          class="session-item"
+          [class.active]="session.id === currentSessionId()"
+          (click)="switchSession(session.id)">
+          <svg class="session-icon" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
+          </svg>
+          <span class="session-title">{{ session.title }}</span>
+          <button class="delete-btn" (click)="deleteSession(session.id, $event)" title="删除">
+            <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
+            </svg>
+          </button>
+        </div>
+        <div class="empty-sessions" *ngIf="sessions().length === 0">
+          暂无历史对话
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- 右侧聊天区域 -->
+  <div class="chat-main">
+    <!-- 聊天头部 -->
+    <div class="chat-header">
+      <!-- 移动端展开侧边栏按钮 -->
+      <button class="sidebar-toggle-btn" (click)="toggleSidebar()" *ngIf="sidebarCollapsed()">
+        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <line x1="3" y1="12" x2="21" y2="12"></line>
+          <line x1="3" y1="6" x2="21" y2="6"></line>
+          <line x1="3" y1="18" x2="21" y2="18"></line>
         </svg>
       </button>
+      <div class="chat-title">
+        <h2>{{ getCurrentSession()?.title || '新对话' }}</h2>
+        <div class="ai-status" [ngClass]="{ 'active': isReceivingResponse }">
+          <span *ngIf="isLoading" class="loading-dots">
+            <span class="dot"></span>
+            <span class="dot"></span>
+            <span class="dot"></span>
+          </span>
+          <span *ngIf="!isLoading && hasHistory()" class="history-badge">
+            💬 {{ getHistoryCount() }}条消息
+          </span>
+          <span *ngIf="!isLoading && !hasHistory()">已连接</span>
+        </div>
+      </div>
+      <div class="header-actions">
+        <button class="action-btn clear-btn" (click)="clearChat()" title="清空当前对话" *ngIf="hasHistory()">
+          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <polyline points="3 6 5 6 21 6"></polyline>
+            <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
+          </svg>
+        </button>
+        <button class="action-btn close-btn" (click)="close.emit()" title="关闭">
+          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="18" y1="6" x2="6" y2="18"></line>
+            <line x1="6" y1="6" x2="18" y2="18"></line>
+          </svg>
+        </button>
+      </div>
     </div>
-  </div>
 
-  <!-- 消息容器 - 自适应高度,固定最大高度 -->
-  <div class="messages-container" #messagesContainer>
-    <!-- 消息列表 -->
-    <div class="messages-list">
-      <ng-container *ngFor="let message of messages()">
-        <!-- 用户消息 -->
-        <div class="message-wrapper user" *ngIf="message.role === 'user'">
-          <div class="message-content">
-            <div class="message-bubble">
-              <span [innerHTML]="formatMessage(message.content)"></span>
-            </div>
-            <div class="message-avatar user-avatar">
-              <span>{{ getInitials('用户') }}</span>
+    <!-- 消息容器 -->
+    <div class="messages-container" #messagesContainer>
+      <div class="messages-list">
+        <ng-container *ngFor="let message of messages()">
+          <!-- 用户消息 -->
+          <div class="message-wrapper user" *ngIf="message.role === 'user'">
+            <div class="message-content">
+              <div class="message-bubble">
+                <span [innerHTML]="formatMessage(message.content)"></span>
+              </div>
+              <div class="message-avatar user-avatar">
+                <span>{{ getInitials('用户') }}</span>
+              </div>
             </div>
+            <div class="message-time">{{ message.timestamp | date: 'HH:mm' }}</div>
           </div>
-          <div class="message-time">{{ message.timestamp | date: 'HH:mm' }}</div>
-        </div>
 
-        <!-- AI消息 -->
-        <div class="message-wrapper assistant" *ngIf="message.role === 'assistant'">
-          <div class="message-content">
-            <div class="message-avatar assistant-avatar">
-              <span>{{ getInitials('AI') }}</span>
-            </div>
-            <div class="message-bubble" [ngClass]="{ 'is-typing': !message.isFinal, 'is-final': message.isFinal }">
-              <!-- 加载中动画 - 仅在没有内容时显示 -->
-              <div *ngIf="message.isLoading && !message.content" class="loading-indicator">
-                <div class="loading-dot"></div>
-                <div class="loading-dot"></div>
-                <div class="loading-dot"></div>
+          <!-- AI消息 -->
+          <div class="message-wrapper assistant" *ngIf="message.role === 'assistant'">
+            <div class="message-content">
+              <div class="message-avatar assistant-avatar">
+                <span>🏃</span>
+              </div>
+              <div class="message-bubble" [ngClass]="{ 'is-typing': !message.isFinal, 'is-final': message.isFinal }">
+                <div *ngIf="message.isLoading && !message.content" class="loading-indicator">
+                  <div class="loading-dot"></div>
+                  <div class="loading-dot"></div>
+                  <div class="loading-dot"></div>
+                </div>
+                <span *ngIf="message.content" [innerHTML]="formatMessage(message.content)"></span>
+                <span *ngIf="!message.isFinal" class="typing-cursor"></span>
               </div>
-              <!-- 始终显示内容,当有内容时优先显示内容 -->
-              <span *ngIf="message.content" [innerHTML]="formatMessage(message.content)"></span>
-              <!-- 打字指示器 - 只要不是最终消息就显示 -->
-              <span *ngIf="!message.isFinal" class="typing-cursor"></span>
             </div>
+            <div class="message-time">{{ message.timestamp | date: 'HH:mm' }}</div>
           </div>
-          <div class="message-time">{{ message.timestamp | date: 'HH:mm' }}</div>
-        </div>
-      </ng-container>
-      <!-- 用于滚动到底部的标记元素 -->
-      <div #messageEnd></div>
+        </ng-container>
+        <div #messageEnd></div>
+      </div>
     </div>
-  </div>
 
-  <!-- 输入区域 -->
-  <div class="input-container">
-    <div class="input-wrapper">
-      <textarea
-        #messageInput
-        class="message-input"
-        [(ngModel)]="userInput"
-        (keydown)="sendMessage($event)"
-        (input)="onInputChange()"
-        placeholder="输入您的问题..."
-        rows="1"
-        spellcheck="false"
-        autocomplete="off"
-      ></textarea>
-      <button
-        class="send-button"
-        (click)="sendMessage()"
-        [disabled]="!userInput.trim() || isLoading"
-        title="发送消息"
-      >
-        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-          <line x1="22" y1="2" x2="11" y2="13"></line>
-          <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
-        </svg>
-      </button>
+    <!-- 输入区域 -->
+    <div class="input-container">
+      <div class="input-wrapper">
+        <textarea
+          #messageInput
+          class="message-input"
+          [(ngModel)]="userInput"
+          (keydown)="sendMessage($event)"
+          (input)="onInputChange()"
+          placeholder="输入您的问题..."
+          rows="1"
+          spellcheck="false"
+          autocomplete="off"
+        ></textarea>
+        <button
+          class="send-button"
+          (click)="sendMessage()"
+          [disabled]="!userInput.trim() || isLoading"
+          title="发送消息"
+        >
+          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <line x1="22" y1="2" x2="11" y2="13"></line>
+            <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
+          </svg>
+        </button>
+      </div>
+      <div class="keyboard-hint">
+        <span>按 Enter 发送,Shift+Enter 换行</span>
+      </div>
     </div>
   </div>
-
-  <!-- 键盘提示 -->
-  <div class="keyboard-hint">
-    <span>按 Enter 发送,Shift+Enter 换行</span>
-  </div>
-</div>
+</div>

+ 354 - 73
campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/ai-chat/ai-chat.component.scss

@@ -1,13 +1,11 @@
-// 企业级AI聊天组件样式 - 移动端优先设计
+// AI聊天组件样式 - 豆包风格分区布局
 
-// 重置和基础样式
 * {
   box-sizing: border-box;
 }
 
 .ai-chat-container {
   display: flex;
-  flex-direction: column;
   height: 100%;
   max-height: 100vh;
   background-color: #f5f7fa;
@@ -15,16 +13,249 @@
   overflow: hidden;
 }
 
+// ==================== 左侧边栏样式 ====================
+.chat-sidebar {
+  width: 260px;
+  min-width: 260px;
+  background-color: #ffffff;
+  border-right: 1px solid #e8e8e8;
+  display: flex;
+  flex-direction: column;
+  transition: all 0.3s ease;
+  overflow: hidden;
+
+  .sidebar-collapsed & {
+    width: 60px;
+    min-width: 60px;
+  }
+}
+
+.sidebar-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 16px;
+  border-bottom: 1px solid #f0f0f0;
+
+  .logo-section {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+
+    .logo-icon {
+      font-size: 24px;
+    }
+
+    .logo-text {
+      font-size: 16px;
+      font-weight: 600;
+      color: #262626;
+      white-space: nowrap;
+    }
+  }
+
+  .toggle-btn {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 28px;
+    height: 28px;
+    border: none;
+    background-color: transparent;
+    border-radius: 6px;
+    cursor: pointer;
+    color: #8c8c8c;
+    transition: all 0.2s ease;
+
+    &:hover {
+      background-color: #f0f0f0;
+      color: #262626;
+    }
+  }
+}
+
+// 新建对话按钮
+.new-chat-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  margin: 12px;
+  padding: 10px 16px;
+  background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
+  border: none;
+  border-radius: 8px;
+  color: white;
+  font-size: 14px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.3s ease;
+
+  &:hover {
+    background: linear-gradient(135deg, #40a9ff 0%, #69c0ff 100%);
+    transform: translateY(-1px);
+    box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
+  }
+
+  &:active {
+    transform: translateY(0);
+  }
+
+  .sidebar-collapsed & {
+    padding: 10px;
+    
+    span {
+      display: none;
+    }
+  }
+}
+
+// 历史对话列表
+.sessions-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 0 12px;
+
+  &::-webkit-scrollbar {
+    width: 4px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: transparent;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: rgba(0, 0, 0, 0.1);
+    border-radius: 2px;
+  }
+
+  .sessions-title {
+    font-size: 12px;
+    color: #8c8c8c;
+    padding: 12px 4px 8px;
+    font-weight: 500;
+  }
+
+  .session-items {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+  }
+
+  .session-item {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 10px 12px;
+    border-radius: 8px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    position: relative;
+
+    &:hover {
+      background-color: #f5f5f5;
+
+      .delete-btn {
+        opacity: 1;
+      }
+    }
+
+    &.active {
+      background-color: #e6f7ff;
+      
+      .session-title {
+        color: #1890ff;
+        font-weight: 500;
+      }
+
+      .session-icon {
+        color: #1890ff;
+      }
+    }
+
+    .session-icon {
+      flex-shrink: 0;
+      color: #8c8c8c;
+    }
+
+    .session-title {
+      flex: 1;
+      font-size: 14px;
+      color: #262626;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .delete-btn {
+      opacity: 0;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 24px;
+      height: 24px;
+      border: none;
+      background-color: transparent;
+      border-radius: 4px;
+      cursor: pointer;
+      color: #8c8c8c;
+      transition: all 0.2s ease;
+
+      &:hover {
+        background-color: #fff1f0;
+        color: #ff4d4f;
+      }
+    }
+  }
+
+  .empty-sessions {
+    padding: 20px;
+    text-align: center;
+    color: #bfbfbf;
+    font-size: 13px;
+  }
+}
+
+// ==================== 右侧聊天区域样式 ====================
+.chat-main {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  background-color: #f5f7fa;
+  position: relative;
+}
+
 // 聊天头部样式
 .chat-header {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  padding: 16px 20px;
-  background-color: #ffffff;
+  padding: 12px 20px;
+  background-color: white;
   border-bottom: 1px solid #e8e8e8;
-  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
   flex-shrink: 0;
+  gap: 12px;
+
+  .sidebar-toggle-btn {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 36px;
+    height: 36px;
+    border-radius: 8px;
+    background-color: #f0f0f0;
+    border: none;
+    color: #595959;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    flex-shrink: 0;
+
+    &:hover {
+      background-color: #e6f7ff;
+      color: #1890ff;
+    }
+  }
 
   .chat-title {
     display: flex;
@@ -33,7 +264,7 @@
 
     h2 {
       margin: 0;
-      font-size: 18px;
+      font-size: 16px;
       font-weight: 600;
       color: #262626;
     }
@@ -42,23 +273,21 @@
       display: flex;
       align-items: center;
       font-size: 12px;
-      color: #8c8c8c;
-      transition: color 0.3s ease;
+      color: #52c41a;
 
       &.active {
-        color: #52c41a;
+        color: #1890ff;
       }
 
       .loading-dots {
         display: flex;
-        align-items: center;
-        gap: 2px;
+        gap: 4px;
 
         .dot {
           width: 6px;
           height: 6px;
+          background-color: #1890ff;
           border-radius: 50%;
-          background-color: currentColor;
           animation: pulse 1.4s infinite ease-in-out both;
 
           &:nth-child(1) {
@@ -70,6 +299,18 @@
           }
         }
       }
+
+      .history-badge {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        padding: 2px 8px;
+        background-color: #e6f7ff;
+        border-radius: 10px;
+        font-size: 11px;
+        color: #1890ff;
+        font-weight: 500;
+      }
     }
   }
 
@@ -180,7 +421,7 @@
       border-radius: 16px;
       word-wrap: break-word;
       font-size: 15px;
-      line-height: 1.4;
+      line-height: 1.5;
       position: relative;
       white-space: pre-wrap;
       overflow-wrap: break-word;
@@ -225,6 +466,7 @@
     .message-avatar.assistant-avatar {
       background-color: #52c41a;
       color: white;
+      font-size: 18px;
     }
 
     .message-bubble {
@@ -236,50 +478,6 @@
       // 打字状态样式
       &.is-typing {
         min-height: 40px;
-
-        // 打字光标动画
-        .typing-cursor {
-            display: inline-block;
-            width: 3px;
-            height: 1.2em;
-            background-color: #1677ff;
-            margin-left: 2px;
-            animation: blink 0.8s infinite;
-            vertical-align: text-bottom;
-            border-radius: 1px;
-          }
-          
-          // 加载动画样式
-          .loading-indicator {
-            display: flex;
-            gap: 6px;
-            padding: 8px 0;
-          }
-          
-          .loading-dot {
-            width: 8px;
-            height: 8px;
-            background-color: #1677ff;
-            border-radius: 50%;
-            animation: loading-bounce 1.4s infinite ease-in-out both;
-          }
-          
-          .loading-dot:nth-child(1) {
-            animation-delay: -0.32s;
-          }
-          
-          .loading-dot:nth-child(2) {
-            animation-delay: -0.16s;
-          }
-          
-          @keyframes loading-bounce {
-            0%, 80%, 100% {
-              transform: scale(0);
-            }
-            40% {
-              transform: scale(1);
-            }
-          }
       }
 
       // 完成状态的过渡效果
@@ -290,6 +488,41 @@
   }
 }
 
+// 打字光标动画
+.typing-cursor {
+  display: inline-block;
+  width: 3px;
+  height: 1.2em;
+  background-color: #1677ff;
+  margin-left: 2px;
+  animation: blink 0.8s infinite;
+  vertical-align: text-bottom;
+  border-radius: 1px;
+}
+
+// 加载动画样式
+.loading-indicator {
+  display: flex;
+  gap: 6px;
+  padding: 8px 0;
+}
+
+.loading-dot {
+  width: 8px;
+  height: 8px;
+  background-color: #1677ff;
+  border-radius: 50%;
+  animation: loading-bounce 1.4s infinite ease-in-out both;
+
+  &:nth-child(1) {
+    animation-delay: -0.32s;
+  }
+
+  &:nth-child(2) {
+    animation-delay: -0.16s;
+  }
+}
+
 // 输入区域样式
 .input-container {
   padding: 16px;
@@ -369,16 +602,13 @@
       }
     }
   }
-}
 
-// 键盘提示样式
-.keyboard-hint {
-  padding: 8px 16px;
-  background-color: #f0f0f0;
-  font-size: 11px;
-  color: #8c8c8c;
-  text-align: center;
-  flex-shrink: 0;
+  .keyboard-hint {
+    padding-top: 8px;
+    font-size: 11px;
+    color: #8c8c8c;
+    text-align: center;
+  }
 }
 
 // 动画定义
@@ -402,14 +632,67 @@
   }
 }
 
-// 响应式设计
+@keyframes loading-bounce {
+  0%, 80%, 100% {
+    transform: scale(0);
+  }
+  40% {
+    transform: scale(1);
+  }
+}
+
+// ==================== 响应式设计 ====================
+
+// 侧边栏折叠状态(所有屏幕尺寸)
+.ai-chat-container.sidebar-collapsed {
+  .chat-sidebar {
+    width: 0;
+    min-width: 0;
+    padding: 0;
+    border-right: none;
+    overflow: hidden;
+  }
+}
+
+// 移动端适配
+@media (max-width: 768px) {
+  .chat-sidebar {
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    z-index: 100;
+    box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
+  }
+
+  .ai-chat-container.sidebar-collapsed {
+    .chat-sidebar {
+      transform: translateX(-100%);
+      opacity: 0;
+      pointer-events: none;
+    }
+  }
+
+  // 展开时添加遮罩效果
+  .ai-chat-container:not(.sidebar-collapsed) {
+    .chat-main::before {
+      content: '';
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background-color: rgba(0, 0, 0, 0.3);
+      z-index: 50;
+    }
+  }
+}
+
 // 平板设备适配
 @media (min-width: 600px) {
   .ai-chat-container {
-    /* 移除固定宽度限制,使其填满父容器 */
     width: 100%;
     height: 100%;
-    /* 移除阴影和边距,由父容器负责 */
     box-shadow: none;
     margin: 0;
     border-radius: 0;
@@ -423,11 +706,9 @@
 // 桌面端适配
 @media (min-width: 1024px) {
   .ai-chat-container {
-    /* 移除最大宽度限制 */
     max-width: none;
   }
 
-  // 悬停效果增强
   .message-bubble {
     transition: transform 0.2s ease;
   }
@@ -492,4 +773,4 @@
       }
     }
   }
-}
+}

+ 306 - 351
campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/ai-chat/ai-chat.component.ts

@@ -14,6 +14,14 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { debounceTime, Subject } from 'rxjs';
 import { DeepSeekService } from '../../../services/deepseek.service';
+import { 
+  SYSTEM_PROMPT, 
+  WELCOME_MESSAGE,
+  CHAT_SESSIONS_STORAGE_KEY,
+  CURRENT_SESSION_KEY,
+  MAX_SESSIONS,
+  MAX_HISTORY_MESSAGES 
+} from './ai-chat.config';
 
 // 定义消息类型接口
 export interface Message {
@@ -21,20 +29,29 @@ export interface Message {
   role: 'user' | 'assistant';
   content: string;
   timestamp: Date;
-  isFinal?: boolean; // 标记消息是否最终完成
-  isLoading?: boolean; // 加载状态标记
+  isFinal?: boolean;
+  isLoading?: boolean;
 }
 
-// 定义流式响应状态接口,增强打字机效果状态管理
+// 定义会话接口
+export interface ChatSession {
+  id: string;
+  title: string;
+  messages: Message[];
+  createdAt: Date;
+  updatedAt: Date;
+}
+
+// 定义流式响应状态接口
 export interface StreamingState {
   isLoading: boolean;
   isReceivingResponse: boolean;
-  lastContent: string;        // 完整内容缓存
-  printerBuffer: string;      // 打字机缓冲区
-  currentDisplayLength: number; // 当前已显示长度
+  lastContent: string;
+  printerBuffer: string;
+  currentDisplayLength: number;
   assistantMessageId: string | null;
-  hasStartedResponse: boolean; // 标记是否已开始接收响应内容
-  isPrinting: boolean;        // 是否正在执行打字机效果
+  hasStartedResponse: boolean;
+  isPrinting: boolean;
 }
 
 @Component({
@@ -43,7 +60,6 @@ export interface StreamingState {
   imports: [CommonModule, FormsModule],
   templateUrl: './ai-chat.component.html',
   styleUrls: ['./ai-chat.component.scss'],
-
 })
 export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
   @Output() close = new EventEmitter<void>();
@@ -53,9 +69,17 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
   @ViewChild('messageInput') private messageInput!: ElementRef;
   @ViewChild('messageEnd') private messageEnd!: ElementRef;
 
-  // 组件状态
+  // 侧边栏状态 - 移动端默认折叠
+  sidebarCollapsed = signal<boolean>(window.innerWidth < 768);
+  
+  // 会话管理
+  sessions = signal<ChatSession[]>([]);
+  currentSessionId = signal<string | null>(null);
+  
+  // 当前会话消息
   messages = signal<Message[]>([]);
   userInput = '';
+  
   private streamingState = signal<StreamingState>({
     isLoading: false,
     isReceivingResponse: false,
@@ -73,48 +97,217 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
   private resizeSubject = new Subject<void>();
 
   // 配置常量
-  private readonly DEFAULT_MODEL = 'deepseek-chat';
-  private readonly PRINT_DELAY = 20; // 打字机效果延迟(ms),加快打字速度以提供更好的体验
-  private readonly SCROLL_DEBOUNCE = 100; // 滚动防抖延迟(ms)
-  private readonly TYPEWRITER_BATCH_SIZE = 2; // 一次显示多个字符,加快打字速度
+  private readonly PRINT_DELAY = 20;
+  private readonly SCROLL_DEBOUNCE = 100;
+  private readonly TYPEWRITER_BATCH_SIZE = 2;
 
   constructor(
     private cdRef: ChangeDetectorRef,
     private deepSeekService: DeepSeekService
   ) {
-    // 初始化输入框自适应高度
     this.resizeSubject.pipe(debounceTime(this.SCROLL_DEBOUNCE)).subscribe(() => {
       this.adjustTextareaHeight();
     });
   }
 
   ngOnInit(): void {
-    // 初始化欢迎消息
-    this.addMessage({
-      role: 'assistant',
-      content: '您好!我是您的健康小助手,有什么我可以帮您的吗?',
-      timestamp: new Date(),
-      isFinal: true,
-    });
+    this.loadSessions();
   }
 
-  ngAfterViewChecked(): void {
-    this.scrollToBottom();
-  }
+  ngAfterViewChecked(): void {}
 
   ngOnDestroy(): void {
-    // 清理资源
     this.cleanupStreamingResources();
     this.resizeSubject.complete();
   }
 
+  // ==================== 会话管理方法 ====================
+
+  /**
+   * 加载所有会话
+   */
+  private loadSessions(): void {
+    try {
+      const savedSessions = localStorage.getItem(CHAT_SESSIONS_STORAGE_KEY);
+      const currentId = localStorage.getItem(CURRENT_SESSION_KEY);
+      
+      if (savedSessions) {
+        const parsed = JSON.parse(savedSessions) as ChatSession[];
+        const restored = parsed.map(s => ({
+          ...s,
+          createdAt: new Date(s.createdAt),
+          updatedAt: new Date(s.updatedAt),
+          messages: s.messages.map(m => ({
+            ...m,
+            timestamp: new Date(m.timestamp),
+            isFinal: true
+          }))
+        }));
+        this.sessions.set(restored);
+        
+        // 恢复当前会话
+        if (currentId && restored.find(s => s.id === currentId)) {
+          this.switchSession(currentId);
+        } else if (restored.length > 0) {
+          this.switchSession(restored[0].id);
+        } else {
+          this.createNewSession();
+        }
+      } else {
+        this.createNewSession();
+      }
+    } catch (error) {
+      console.warn('加载会话失败:', error);
+      this.createNewSession();
+    }
+  }
+
+  /**
+   * 保存所有会话
+   */
+  private saveSessions(): void {
+    try {
+      const allSessions = this.sessions();
+      const toSave = allSessions.slice(0, MAX_SESSIONS);
+      localStorage.setItem(CHAT_SESSIONS_STORAGE_KEY, JSON.stringify(toSave));
+      
+      const currentId = this.currentSessionId();
+      if (currentId) {
+        localStorage.setItem(CURRENT_SESSION_KEY, currentId);
+      }
+    } catch (error) {
+      console.warn('保存会话失败:', error);
+    }
+  }
+
+  /**
+   * 创建新会话
+   */
+  createNewSession(): void {
+    // 清理当前流式响应
+    this.cleanupStreamingResources();
+    
+    const newSession: ChatSession = {
+      id: this.generateUniqueId(),
+      title: '新对话',
+      messages: [],
+      createdAt: new Date(),
+      updatedAt: new Date()
+    };
+    
+    // 添加欢迎消息
+    const welcomeMessage: Message = {
+      id: this.generateUniqueId(),
+      role: 'assistant',
+      content: WELCOME_MESSAGE,
+      timestamp: new Date(),
+      isFinal: true
+    };
+    newSession.messages.push(welcomeMessage);
+    
+    // 更新会话列表(新会话放在最前面)
+    this.sessions.update(sessions => [newSession, ...sessions]);
+    this.currentSessionId.set(newSession.id);
+    this.messages.set(newSession.messages);
+    
+    this.saveSessions();
+    
+    // 滚动到底部
+    setTimeout(() => this.scrollToBottom('smooth', true), 0);
+  }
+
+  /**
+   * 切换会话
+   */
+  switchSession(sessionId: string): void {
+    if (sessionId === this.currentSessionId()) return;
+    
+    // 清理当前流式响应
+    this.cleanupStreamingResources();
+    
+    const session = this.sessions().find(s => s.id === sessionId);
+    if (session) {
+      this.currentSessionId.set(sessionId);
+      this.messages.set([...session.messages]);
+      this.saveSessions();
+      
+      setTimeout(() => this.scrollToBottom('auto', true), 0);
+    }
+  }
+
+  /**
+   * 删除会话
+   */
+  deleteSession(sessionId: string, event: Event): void {
+    event.stopPropagation();
+    
+    if (!confirm('确定要删除这个对话吗?')) return;
+    
+    this.sessions.update(sessions => sessions.filter(s => s.id !== sessionId));
+    
+    // 如果删除的是当前会话,切换到第一个或创建新会话
+    if (sessionId === this.currentSessionId()) {
+      const remaining = this.sessions();
+      if (remaining.length > 0) {
+        this.switchSession(remaining[0].id);
+      } else {
+        this.createNewSession();
+      }
+    }
+    
+    this.saveSessions();
+  }
+
+  /**
+   * 更新当前会话
+   */
+  private updateCurrentSession(): void {
+    const currentId = this.currentSessionId();
+    if (!currentId) return;
+    
+    const currentMessages = this.messages();
+    
+    this.sessions.update(sessions => 
+      sessions.map(s => {
+        if (s.id === currentId) {
+          // 根据第一条用户消息生成标题
+          const firstUserMsg = currentMessages.find(m => m.role === 'user');
+          const title = firstUserMsg 
+            ? firstUserMsg.content.slice(0, 20) + (firstUserMsg.content.length > 20 ? '...' : '')
+            : '新对话';
+          
+          return {
+            ...s,
+            title,
+            messages: currentMessages.filter(m => m.isFinal),
+            updatedAt: new Date()
+          };
+        }
+        return s;
+      }).sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
+    );
+    
+    this.saveSessions();
+  }
+
+  /**
+   * 获取当前会话
+   */
+  getCurrentSession(): ChatSession | undefined {
+    return this.sessions().find(s => s.id === this.currentSessionId());
+  }
+
   /**
-   * 发送消息处理
+   * 切换侧边栏
    */
+  toggleSidebar(): void {
+    this.sidebarCollapsed.update(v => !v);
+  }
+
+  // ==================== 消息处理方法 ====================
+
   sendMessage(event?: Event): void {
-    // 处理键盘事件
     if (event instanceof KeyboardEvent) {
-      // Shift+Enter换行,其他Enter发送
       if (event.key === 'Enter' && !event.shiftKey) {
         event.preventDefault();
       } else {
@@ -122,13 +315,11 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
       }
     }
 
-    // 输入验证
     const trimmedInput = this.userInput.trim();
     if (!trimmedInput || this.streamingState().isLoading) {
       return;
     }
 
-    // 添加用户消息
     this.addMessage({
       role: 'user',
       content: trimmedInput,
@@ -136,67 +327,49 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
       isFinal: true,
     });
 
-    // 清空输入框并重置高度
     this.userInput = '';
     this.adjustTextareaHeight();
-
-    // 发送给AI处理
     this.sendToAI(trimmedInput);
   }
 
-  /**
-   * 清空聊天记录
-   */
   clearChat(): void {
-    // 清理资源
+    if (!confirm('确定要清空当前对话吗?')) return;
+    
     this.cleanupStreamingResources();
-
-    // 重置状态
-    this.messages.set([]);
-    this.userInput = '';
-    this.streamingState.update(state => ({
-      ...state,
-      isLoading: false,
-      isReceivingResponse: false,
-      assistantMessageId: null,
-      currentDisplayLength: 0,
-    }));
-
-    // 重新添加欢迎消息
-    this.addMessage({
+    
+    // 重置当前会话消息
+    const welcomeMessage: Message = {
+      id: this.generateUniqueId(),
       role: 'assistant',
-      content: '您好!我是您的健康小助手,有什么我可以帮您的吗?',
+      content: WELCOME_MESSAGE,
       timestamp: new Date(),
-      isFinal: true,
-    });
+      isFinal: true
+    };
+    
+    this.messages.set([welcomeMessage]);
+    this.updateCurrentSession();
+  }
+
+  getHistoryCount(): number {
+    return this.messages().filter(msg => msg.isFinal).length;
+  }
+
+  hasHistory(): boolean {
+    return this.getHistoryCount() > 1;
   }
 
-  /**
-   * 处理文本区域输入变化
-   */
   onInputChange(): void {
     this.resizeSubject.next();
   }
 
-  /**
-   * 从文本获取首字母用于头像显示
-   */
   getInitials(text: string): string {
     if (!text || typeof text !== 'string') return 'U';
-
-    // 提取非空字符
     const nonEmptyText = text.trim();
     if (!nonEmptyText) return 'U';
-
-    // 获取首字母并转为大写
     return nonEmptyText.charAt(0).toUpperCase();
   }
 
-  /**
-   * 向AI发送用户输入并处理流式响应
-   */
   private async sendToAI(userInput: string): Promise<void> {
-    // 更新状态
     this.streamingState.update(state => ({
       ...state,
       isLoading: true,
@@ -209,18 +382,18 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
       hasStartedResponse: false,
     }));
 
-    // 构建消息上下文
-    const messageContext = this.messages().map(msg => ({
-      role: msg.role,
-      content: msg.content,
-    }));
+    const messageContext = [
+      { role: 'system', content: SYSTEM_PROMPT },
+      ...this.messages().map(msg => ({
+        role: msg.role,
+        content: msg.content,
+      }))
+    ];
 
     try {
-      // 创建初始AI消息
       const initialMessageId = this.createInitialAssistantMessage();
       this.streamingState.update(state => ({ ...state, assistantMessageId: initialMessageId }));
 
-      // 调用 DeepSeek API 进行流式对话
       this.subscription = this.deepSeekService.sendMessage(messageContext).subscribe({
         next: this.handleStreamingNext.bind(this),
         error: this.handleStreamingError.bind(this),
@@ -232,16 +405,9 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
     }
   }
 
-  /**
-   * 处理流式响应的next事件 - 实现真正的打字机效果
-   */
   private handleStreamingNext(message: any): void {
-    console.log('收到流式响应:', message);
-    console.log('🔄 收到流式响应:', message);
-    // 增强对不同响应格式的支持
     let content = message?.content || '';
 
-    // 支持OpenAI格式的流式响应
     if (!content && message?.choices && message.choices[0]) {
       if (message.choices[0].delta && message.choices[0].delta.content) {
         content = message.choices[0].delta.content;
@@ -250,57 +416,35 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
       }
     }
 
-    const isComplete = message?.complete || false;
-    console.log('📝 提取到的内容:', content, '完成标志:', isComplete);
-
-    // 处理新增内容,实现打字机效果
     if (content) {
       const state = this.streamingState();
-
-      // 优化增量内容处理逻辑,支持多种响应格式
       let newContent = '';
+      
       if (content.startsWith(state.lastContent) && state.lastContent.length > 0) {
-        // 处理完整内容格式(包含之前的所有内容)
         newContent = content.substring(state.lastContent.length);
       } else {
-        // 处理直接增量格式(只包含新增部分)
         newContent = content;
       }
 
-      console.log('🔄 增量内容处理:', { newContent, lastContent: state.lastContent });
-
-      // 更新最后内容状态,添加响应开始标记
       const updatedContent = state.lastContent + newContent;
       this.streamingState.update(s => ({
         ...s,
         lastContent: updatedContent,
-        printerBuffer: updatedContent, // 更新打字机缓冲区
-        hasStartedResponse: true, // 标记响应已开始
-        isReceivingResponse: true  // 确保状态保持为true
+        printerBuffer: updatedContent,
+        hasStartedResponse: true,
+        isReceivingResponse: true
       }));
 
-      console.log('📊 状态更新:', { updatedContent, hasStartedResponse: true });
-
-      // 如果还没有开始打字,启动打字机效果
       if (newContent && state.assistantMessageId && !state.isPrinting) {
-        console.log('🚀 启动打字机效果');
         this.startTypewriterEffect();
-      } else if (state.isPrinting) {
-        console.log('⏩ 打字机正在运行,添加新内容到缓冲区');
       }
     }
   }
 
-  /**
-   * 处理流式响应的error事件
-   */
   private handleStreamingError(error: any): void {
     console.error('AI响应错误:', error);
-
-    // 清理资源
     this.cleanupStreamingResources();
 
-    // 显示错误消息
     this.addMessage({
       role: 'assistant',
       content: '抱歉,处理您的请求时遇到了技术问题,请稍后再试。',
@@ -309,36 +453,20 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
     });
   }
 
-  /**
-   * 处理流式响应的complete事件 - 结束响应处理
-   */
   private handleStreamingComplete(): void {
-    console.log('✅ 流式响应完成,最终状态:', this.streamingState());
-
-    // 标记响应已接收完成
     this.streamingState.update(s => ({ ...s, isReceivingResponse: false }));
-
     const state = this.streamingState();
 
     if (state.assistantMessageId && state.lastContent) {
-      // 数据完全返回后,直接显示全部内容,不再继续打字机效果
-      console.log('📝 数据完全返回,直接显示完整内容并标记为最终状态');
-      console.log('📊 最终内容长度:', state.lastContent.length);
-
-      // 确保显示完整内容,不设置任何长度限制
       this.updateMessageDisplay(state.assistantMessageId, state.lastContent, true);
-
-      // 停止任何正在运行的打字机效果
       this.clearTypewriterInterval();
 
-      // 更新状态
       this.streamingState.update(s => ({
         ...s,
         isPrinting: false,
         currentDisplayLength: state.lastContent.length
       }));
 
-      // 标记消息为最终状态
       this.messages.update(msgs =>
         msgs.map(msg =>
           msg.id === state.assistantMessageId
@@ -347,208 +475,56 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
         )
       );
 
-      // 清理资源 - 现在isReceivingResponse为false,可以安全地清理订阅
+      this.updateCurrentSession();
       this.cleanupStreamingResources();
     } else {
-      // 没有消息ID或内容时,直接清理资源
       this.cleanupStreamingResources();
     }
 
-    // 强制更新UI并滚动到底部
     setTimeout(() => {
       this.cdRef.detectChanges();
       this.scrollToBottom();
     }, 0);
   }
 
-  /**
-   * 处理新增内容 - 打字机效果核心逻辑(已简化为直接显示)
-   */
-  private processNewContent(content: string, isFinal: boolean): void {
-    // 此方法现在已被优化,直接在handleStreamingNext中处理内容更新
-  }
-
-  /**
-   * 启动打字机效果 - 实现逐字符显示的真正打字机效果
-   */
   private startTypewriterEffect(): void {
-    // 清理可能存在的定时器
     this.clearTypewriterInterval();
-
-    // 标记为正在打字
     this.streamingState.update(s => ({ ...s, isPrinting: true }));
-
-    // 立即显示一批字符而不是单个字符
     this.advanceTypewriter();
 
-    // 设置定时器进行逐字符显示 - 使用更快的打字速度
     this.printerInterval = window.setInterval(() => {
       this.advanceTypewriter();
     }, this.PRINT_DELAY);
-
-    console.log('⏱️ 打字机定时器已启动,延迟:', this.PRINT_DELAY);
   }
 
-  /**
-   * 打字机步进函数 - 显示多个字符,加快打字速度
-   */
   private advanceTypewriter(): void {
     const state = this.streamingState();
 
-    // 检查是否还有内容需要显示
     if (state.currentDisplayLength < state.printerBuffer.length && state.assistantMessageId) {
-      // 显示更多字符,每次显示多个字符而不是一个
       const nextLength = Math.min(state.currentDisplayLength + this.TYPEWRITER_BATCH_SIZE, state.printerBuffer.length);
       const displayContent = state.printerBuffer.substring(0, nextLength);
 
-      console.log('🔤 打字机步进:', {
-        currentLength: state.currentDisplayLength,
-        nextLength,
-        totalLength: state.printerBuffer.length,
-        batchSize: this.TYPEWRITER_BATCH_SIZE
-      });
-
-      // 更新显示内容
       this.updateMessageDisplay(state.assistantMessageId, displayContent);
-
-      // 更新状态
       this.streamingState.update(s => ({ ...s, currentDisplayLength: nextLength }));
 
-      // 确保滚动到底部
-      setTimeout(() => {
-        this.scrollToBottom();
-      }, 0);
+      setTimeout(() => this.scrollToBottom('auto', false), 0);
     } else if (state.currentDisplayLength >= state.printerBuffer.length) {
-      // 所有内容都已显示,结束打字机效果
-      console.log('✅ 打字机完成显示所有内容');
       this.finalizeTypewriter();
     }
   }
 
-  /**
-   * 更新消息显示内容 - 支持清除加载状态
-   */
-  /**
-   * 更新消息显示
-   * @param messageId 消息ID
-   * @param content 消息内容
-   * @param clearLoading 是否清除加载状态
-   */
-  private updateMessageDisplay(messageId: string, content: string, clearLoading: boolean = false): void {
-    console.log('🖊️ 更新消息显示:', { messageId, content, clearLoading, contentLength: content.length });
-    this.messages.update(msgs =>
-      msgs.map(msg =>
-        msg.id === messageId
-          ? {
-            ...msg,
-            content,
-            isLoading: clearLoading ? false : msg.isLoading
-          }
-          : msg
-      )
-    );
-    // 强制变更检测
-    this.cdRef.detectChanges();
-    console.log('✅ UI更新完成');
-  }
-
-  /**
-   * 完成打字机效果
-   */
-
-
-  /**
-   * 显示所有剩余内容
-   * 确保完整内容显示,不设置长度限制
-   */
-  private displayAllContent(): void {
-    const state = this.streamingState();
-
-    if (state.assistantMessageId && state.printerBuffer) {
-      console.log('📤 显示完整内容:', { bufferLength: state.printerBuffer.length, isComplete: state.isReceivingResponse });
-      // 直接显示完整内容,不设置任何长度限制
-      this.updateMessageDisplay(state.assistantMessageId, state.printerBuffer, true);
-
-      // 停止打字机效果
-      this.clearTypewriterInterval();
-
-      // 更新状态,标记打字机完成
-      this.streamingState.update(s => ({
-        ...s,
-        isPrinting: false,
-        currentDisplayLength: state.printerBuffer.length
-      }));
-
-      // 确保滚动到底部
-      setTimeout(() => {
-        this.scrollToBottom();
-      }, 0);
-    }
-  }
-
-  /**
-   * 完成消息显示并添加完成动画标记
-   */
-  private finalizeMessageDisplay(): void {
-    // 确保显示所有剩余内容,在响应完成时立即展示
-    const state = this.streamingState();
-
-    // 如果正在打字,先结束打字机效果
-    if (state.isPrinting) {
-      console.log('🎯 响应完成,结束打字机效果并显示完整内容');
-      // 取消打字机定时器
-      this.clearTypewriterInterval();
-
-      // 更新状态
-      this.streamingState.update(s => ({
-        ...s,
-        isPrinting: false,
-        currentDisplayLength: state.printerBuffer.length
-      }));
-    }
-
-    if (state.assistantMessageId && state.lastContent) {
-      // 直接使用累计的完整内容,而不仅仅是当前的buffer
-      this.updateMessageDisplay(state.assistantMessageId, state.lastContent);
-
-      // 标记消息为最终状态并清除加载状态
-      this.messages.update(msgs =>
-        msgs.map(msg =>
-          msg.id === state.assistantMessageId
-            ? { ...msg, isFinal: true, isLoading: false }
-            : msg
-        )
-      );
-    }
-
-    // 清理状态
-    this.cleanupStreamingResources();
-
-    // 强制更新UI并滚动到底部
-    setTimeout(() => {
-      this.cdRef.detectChanges();
-      this.scrollToBottom();
-    }, 0);
-  }
-
-  /**
-   * 创建初始AI助手消息 - 添加明确的加载状态
-   */
   private createInitialAssistantMessage(): string {
-    console.log('🔄 创建初始助手消息');
-    // 创建新的AI消息,添加初始反馈文本
     const message: Message = {
       id: this.generateUniqueId(),
       role: 'assistant',
-      content: '', // 空内容,等待实时更新
+      content: '',
       timestamp: new Date(),
       isFinal: false,
-      isLoading: true // 添加加载状态
+      isLoading: true
     };
 
     this.messages.update(msgs => [...msgs, message]);
 
-    // 更新状态
     this.streamingState.update(s => ({
       ...s,
       assistantMessageId: message.id,
@@ -556,12 +532,10 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
       lastContent: ''
     }));
 
+    setTimeout(() => this.scrollToBottom('smooth', true), 0);
     return message.id;
   }
 
-  /**
-   * 添加新消息
-   */
   private addMessage(messageData: Omit<Message, 'id'>): void {
     const message: Message = {
       ...messageData,
@@ -569,44 +543,62 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
     };
 
     this.messages.update(msgs => [...msgs, message]);
+    
+    if (message.isFinal) {
+      this.updateCurrentSession();
+    }
+    
+    setTimeout(() => this.scrollToBottom('smooth', true), 0);
   }
 
-  /**
-   * 清理流式处理资源
-   * 确保只有在响应真正完成时才取消订阅,避免输出被截断
-   */
-  private cleanupStreamingResources(): void {
-    console.log('🧹 清理流式处理资源');
+  private isUserNearBottom(): boolean {
+    if (!this.messagesContainer?.nativeElement) return false;
+    const element = this.messagesContainer.nativeElement;
+    const threshold = 150;
+    const position = element.scrollHeight - element.scrollTop - element.clientHeight;
+    return position <= threshold;
+  }
 
-    // 打字机定时器总是清理,避免内存泄漏
-    this.clearTypewriterInterval();
+  private scrollToBottom(behavior: ScrollBehavior = 'smooth', force: boolean = false): void {
+    try {
+      if (this.messagesContainer?.nativeElement) {
+        if (!force && !this.isUserNearBottom()) return;
+
+        if (this.messageEnd?.nativeElement) {
+          this.messageEnd.nativeElement.scrollIntoView({
+            behavior,
+            block: 'end'
+          });
+        }
+      }
+    } catch (err) {
+      console.warn('滚动到底部失败:', err);
+    }
+  }
+
+  private updateMessageDisplay(messageId: string, content: string, clearLoading: boolean = false): void {
+    this.messages.update(msgs =>
+      msgs.map(msg =>
+        msg.id === messageId
+          ? { ...msg, content, isLoading: clearLoading ? false : msg.isLoading }
+          : msg
+      )
+    );
+    this.cdRef.detectChanges();
+  }
 
-    // 关键修改:只有在响应真正完成时才清理订阅
-    // 流式响应过程中不应该取消订阅,避免输出被截断
+  private cleanupStreamingResources(): void {
+    this.clearTypewriterInterval();
     const isReceivingResponse = this.streamingState()?.isReceivingResponse ?? false;
-    console.log('📡 订阅清理状态检查:', { hasSubscription: !!this.subscription, isReceivingResponse });
 
     if (this.subscription && !isReceivingResponse) {
-      console.log('📭 响应已完成,安全清理订阅');
       this.subscription.unsubscribe();
       this.subscription = null;
-    } else if (this.subscription && isReceivingResponse) {
-      console.warn('⚠️ 响应仍在接收中,不清理订阅以避免输出被截断');
     }
 
-    // 重置状态,但保留printerBuffer和相关状态,避免影响正在进行的打字机效果
-    this.streamingState.update(state => ({
-      ...state, // 保留当前所有状态
-      isLoading: false,
-      // 不自动重置isReceivingResponse,由complete事件处理
-    }));
-
-    console.log('📊 状态已重置(保留打字机相关状态)');
+    this.streamingState.update(state => ({ ...state, isLoading: false }));
   }
 
-  /**
-   * 清除打字机效果定时器
-   */
   private clearTypewriterInterval(): void {
     if (this.printerInterval !== null) {
       clearInterval(this.printerInterval);
@@ -614,24 +606,15 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
     }
   }
 
-  /**
-   * 完成打字机效果,处理最终显示
-   */
   private finalizeTypewriter(): void {
     this.clearTypewriterInterval();
     const state = this.streamingState();
 
-    // 更新状态
     this.streamingState.update(s => ({ ...s, isPrinting: false }));
 
-    console.log('🏁 打字机效果完成');
-
-    // 确保显示完整内容并标记为最终状态
     if (state.assistantMessageId) {
-      // 显示完整内容并清除加载状态
       this.updateMessageDisplay(state.assistantMessageId, state.printerBuffer, true);
-
-      // 标记消息为最终状态
+      
       this.messages.update(msgs =>
         msgs.map(msg =>
           msg.id === state.assistantMessageId
@@ -639,33 +622,12 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
             : msg
         )
       );
-
-      console.log('✅ 消息已标记为最终状态');
-
-      // 清理资源
+      
+      this.updateCurrentSession();
       this.cleanupStreamingResources();
     }
   }
 
-  /**
-   * 滚动到底部
-   */
-  private scrollToBottom(): void {
-    try {
-      if (this.messageEnd && this.messageEnd.nativeElement) {
-        this.messageEnd.nativeElement.scrollIntoView({
-          behavior: 'smooth',
-          block: 'end'
-        });
-      }
-    } catch (err) {
-      console.warn('滚动到底部失败:', err);
-    }
-  }
-
-  /**
-   * 调整文本区域高度
-   */
   private adjustTextareaHeight(): void {
     try {
       if (this.messageInput && this.messageInput.nativeElement) {
@@ -678,21 +640,14 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
     }
   }
 
-  /**
-   * 格式化消息内容,将换行符转换为HTML换行标签
-   */
   formatMessage(content: string): string {
     return content ? content.replace(/\n/g, '<br>') : '';
   }
 
-  /**
-   * 生成唯一ID
-   */
   private generateUniqueId(): string {
-    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+    return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
   }
 
-  // 获取器方法,用于模板访问状态
   get isLoading(): boolean {
     return this.streamingState().isLoading;
   }
@@ -700,4 +655,4 @@ export class AiChatComponent implements OnInit, AfterViewChecked, OnDestroy {
   get isReceivingResponse(): boolean {
     return this.streamingState().isReceivingResponse;
   }
-}
+}

+ 82 - 0
campus_health_app/frontend/campus-health-app/src/app/shared/components/modal/ai-chat/ai-chat.config.ts

@@ -0,0 +1,82 @@
+// 聊天历史记录存储键
+export const CHAT_SESSIONS_STORAGE_KEY = 'ai_chat_sessions'; // 会话列表
+export const CURRENT_SESSION_KEY = 'ai_chat_current_session'; // 当前会话ID
+export const MAX_SESSIONS = 50; // 最大会话数量
+export const MAX_HISTORY_MESSAGES = 100; // 每个会话最大消息数量
+
+// 兼容旧版本
+export const CHAT_HISTORY_STORAGE_KEY = 'ai_chat_history';
+
+export const SYSTEM_PROMPT = `
+# Role
+你是一名**校园私人运动规划师**,专注服务在校学生(覆盖小学至大学),结合校园生活场景(如上课、自习、社团活动、食堂饮食),为学生提供「个性化运动计划+适配饮食方案+执行监督」的一站式服务。你的定位是「懂学生的运动伙伴」——专业但不高冷,用接地气的语言(避免晦涩术语)传递运动科学,用鼓励式沟通推动用户坚持。
+
+## 重要:历史记录与上下文
+- 你能够记住与用户之前的所有对话内容
+- 当用户回来继续对话时,你应该主动回顾之前的计划和进度
+- 如果用户之前制定过运动/饮食计划,要主动询问执行情况
+- 根据历史对话中用户提供的信息(年级、目标、身体状况等)进行个性化回复
+- 如果是新用户或清空了历史记录,则按照正常流程从头开始收集信息
+
+## Skills
+1. **饮食营养能力**:掌握常见校园饮食(食堂菜、外卖、零食)的热量数据库(如米饭100g≈116kcal、食堂红烧肉100g≈350kcal)、饮食结构搭配原则(如蛋白质/碳水/脂肪比例)、学生常见饮食误区纠正(如"不吃早餐减肥""奶茶替代水");
+2. **运动科学能力**:精通运动生理基础(如心率区间、肌肉恢复周期)、校园场景运动设计(如利用课间10分钟拉伸、操场夜跑计划、体育馆社团运动结合)、运动专项技能指导(如跑步姿势调整、篮球运球训练、瑜伽体态改善);
+3. **营养与康复基础**:能识别学生常见运动损伤(如跑步膝、腰部拉伤)并提供基础康复建议(如冰敷、拉伸动作),掌握运动前后营养补充逻辑(如运动后30分钟补充蛋白质);
+4. **沟通与教学能力**:用学生易懂的语言拆解专业知识(如把"有氧代谢"说成"持续跑10分钟以上,身体开始烧脂肪"),擅长用「校园场景举例」引导行动(如"早八前10分钟可以在操场做5组高抬腿,比爬楼梯更燃脂"),自带鼓励buff(如"这周完成了3次运动,比上周多1次,超棒!")。
+
+## Goals
+为学生制定**适配校园时间/场地/饮食的个性化运动计划**,并通过定期监督帮助用户完成目标(如增强体质、提升专项技能、改善体态、健康减重)。
+
+## Workflow
+### 步骤1:用户信息收集(必填,奠定计划基础)
+- **执行标准**:用「轻松提问+校园场景引导」获取关键信息,避免用户反感:
+  1. 基础情况:年级(如"大一年级""初二")、日常时间安排(如"早8-晚10上课/自习,晚10-11有空""周三下午社团活动");
+  2. 运动现状:当前每周运动次数(如"1次""几乎不运动")、擅长/喜欢的运动(如"喜欢打羽毛球""跑步会累但能坚持")、身体限制(如"膝盖有旧伤,不能跑跳""对花生过敏");
+  3. 核心目标:明确用户需求(如"想瘦10斤""提升篮球运球能力""改善含胸驼背""增强免疫力少生病")。
+- **引导话术**:"同学你好呀~先和我聊聊你的情况吧:读几年级?平时上课/自习之外,大概什么时候有空运动?喜欢打羽毛球/跑步这类的运动吗?有没有哪里不舒服(比如膝盖疼)?这次想通过运动达成什么小目标呀?"
+
+### 步骤2:饮食信息收集(关键辅助)
+- **执行标准**:聚焦「校园常见饮食场景」,问清用户的真实饮食习惯(避免"理想型"回答):
+  1. 日常饮食结构:早餐(如"吃包子+豆浆""来不及吃")、午餐/晚餐(如"食堂打2荤1素""常点外卖黄焖鸡")、加餐(如"下午会吃饼干""喝奶茶");
+  2. 饮食偏好:爱吃/讨厌的食物(如"超爱红烧肉""不吃蔬菜")、是否有忌口(如"素食""不吃辣");
+  3. 热量认知:用户是否了解常见食物热量(如"知道奶茶热量高,但忍不住喝""不清楚食堂菜的热量")。
+- **引导话术**:"再和我说说你平时在学校的吃饭情况吧~早餐一般吃什么?午餐是食堂还是外卖?下午会不会加餐?有没有特别爱吃或者完全不想碰的食物呀?"
+
+### 步骤3:方案分析与制定(专业输出)
+- **执行标准**:结合前两步信息,输出**「校园可落地」的运动+饮食计划**,要求:
+  1. 运动计划:
+     - 时间适配:贴合用户的空闲时段(如"早八前10分钟:操场做5组高抬腿+拉伸""晚自习后30分钟:体育馆打羽毛球");
+     - 场地适配:优先用校园现有场地(操场、体育馆、走廊、教室),避免"去健身房"这类不现实的建议;
+     - 强度适配:根据用户运动基础调整(如新手从"每周3次,每次20分钟快走"开始,进阶用户可以"每周4次,每次30分钟跑步+10分钟力量训练");
+     - 目标适配:如果是"提升篮球技能",则加入"每周2次,每次15分钟运球练习+10分钟投篮";如果是"改善含胸",则加入"每天课间做5分钟扩胸运动"。
+  2. 饮食计划:
+     - 替换建议:用「校园常见食物」替代高热量选项(如"把奶茶换成无糖豆浆""把炸鸡换成食堂卤鸡腿");
+     - 搭配建议:针对目标调整(如"想减肥:午餐减少1/3米饭,多吃青菜""想增肌:运动后30分钟吃1个鸡蛋+一杯牛奶");
+     - 热量提醒:用"直观对比"让用户理解(如"一杯全糖奶茶≈跑5公里才能消耗""食堂红烧肉100g≈350kcal,相当于1碗半米饭")。
+  3. 康复/注意事项:针对身体限制给出建议(如"膝盖有伤:避免跑跳,选择游泳或椭圆机""运动后酸痛:用泡沫轴滚小腿5分钟缓解")。
+
+### 步骤4:监督与调整(闭环关键)
+- **执行标准**:
+  1. 定期跟进:根据用户的计划周期,主动询问执行情况;
+  2. 问题解决:针对用户的阻碍给出解决方案;
+  3. 计划优化:根据执行反馈调整。
+
+### 步骤5:目标庆祝与长期激励
+- **执行标准**:当用户达成小目标时,给予正向反馈,强化用户的坚持动力。
+
+## Constrains
+1. **专业边界**:不提供医疗诊断,不推荐危险运动;
+2. **饮食限制**:不建议极端饮食,所有饮食建议必须符合"校园可获得";
+3. **时间限制**:运动计划必须贴合学生的上课/自习时间;
+4. **沟通限制**:不用命令式语言,改用鼓励式表达。
+
+## Output Format
+最终输出的计划需包含3个部分,用「小标题+ bullet point」呈现,语言口语化:
+1. 【你的运动计划】(时间、场地、内容、频率);
+2. 【你的饮食建议】(校园可获得的食物替换、加餐建议、注意事项);
+3. 【小提醒】(身体保护、执行技巧)。
+`;
+
+export const WELCOME_MESSAGE = '同学你好呀~我是你的校园私人运动规划师!专门帮你制定「在学校就能轻松完成」的运动计划,还会教你怎么吃食堂/外卖不胖~ 先和我聊聊你的情况吧:读几年级?平时什么时候有空运动?喜欢打羽毛球/跑步吗?这次想通过运动达成什么小目标呀?';
+
+export const WELCOME_BACK_MESSAGE = '欢迎回来呀~我记得我们之前聊过!让我看看你的运动计划执行得怎么样了?有什么新的问题或者想调整的地方吗?';