Explorar el Código

Release v0.2.21304

qdy hace 2 semanas
commit
58d255510a

+ 99
- 0
src/app/components/ai-response.component.html Ver fichero

@@ -0,0 +1,99 @@
1
+<div class="ai-response-container">
2
+  <!-- 头部工具栏 -->
3
+  <div class="header-toolbar">
4
+    <div class="session-info">
5
+      @if (currentResponse) {
6
+        <div class="type-indicator" [style.backgroundColor]="getTypeColor(getCurrentType())">
7
+          <mat-icon class="type-icon">{{ getTypeIcon(getCurrentType()) }}</mat-icon>
8
+          <span class="type-label">{{ getTypeLabel(getCurrentType()) }}</span>
9
+        </div>
10
+        
11
+        <div class="session-id">
12
+          <mat-icon>fingerprint</mat-icon>
13
+          <span>{{ getCurrentSessionId() | slice:0:8 }}...</span>
14
+        </div>
15
+        
16
+        <div class="timestamp">
17
+          <mat-icon>schedule</mat-icon>
18
+          <span>{{ getFormattedTimestamp() }}</span>
19
+        </div>
20
+      } @else {
21
+        <div class="empty-state">
22
+          <mat-icon>smart_toy</mat-icon>
23
+          <span>等待AI回复...</span>
24
+        </div>
25
+      }
26
+    </div>
27
+    
28
+    <div class="toolbar-actions">
29
+      <button mat-icon-button matTooltip="切换完整/折叠视图" (click)="toggleFullText()">
30
+        <mat-icon>{{ showFullText ? 'unfold_less' : 'unfold_more' }}</mat-icon>
31
+      </button>
32
+      <button mat-icon-button matTooltip="清空响应" (click)="clearResponses()">
33
+        <mat-icon>delete</mat-icon>
34
+      </button>
35
+    </div>
36
+  </div>
37
+  
38
+  <!-- 内容区域 -->
39
+  <div class="content-area" [class.streaming]="isStreaming" [class.collapsed]="!showFullText">
40
+    @if (currentResponse) {
41
+      <div class="content-wrapper">
42
+        <!-- 响应内容 -->
43
+        <div class="response-content" [innerHTML]="getDisplayContent() | markdown"></div>
44
+        
45
+        <!-- 流式传输进度 -->
46
+        @if (isStreaming) {
47
+          <div class="streaming-indicator">
48
+            <mat-progress-bar mode="determinate" [value]="streamingProgress"></mat-progress-bar>
49
+            <div class="streaming-text">
50
+              <mat-icon>hourglass_empty</mat-icon>
51
+              <span>AI正在思考... {{ streamingProgress | number:'1.0-0' }}%</span>
52
+            </div>
53
+          </div>
54
+        } @else {
55
+          <div class="response-complete">
56
+            <mat-icon>check_circle</mat-icon>
57
+            <span>回复完成</span>
58
+          </div>
59
+        }
60
+      </div>
61
+    } @else {
62
+      <div class="no-response">
63
+        <mat-icon class="empty-icon">chat</mat-icon>
64
+        <p>暂无AI回复内容</p>
65
+        <p class="hint">AI的回复将实时显示在这里</p>
66
+      </div>
67
+    }
68
+  </div>
69
+  
70
+  <!-- 响应历史(可选) -->
71
+  @if (responses.length > 1) {
72
+    <div class="response-history">
73
+      <div class="history-header">
74
+        <mat-icon>history</mat-icon>
75
+        <span>历史响应 ({{ responses.length - 1 }})</span>
76
+      </div>
77
+      <div class="history-list">
78
+        @for (response of responses.slice(-5); track $index) {
79
+          @if (response !== currentResponse) {
80
+            <div class="history-item" 
81
+                 [style.borderLeftColor]="getTypeColor(response.type)"
82
+                 (click)="currentResponse = response">
83
+              <div class="history-type-icon">
84
+                <mat-icon>{{ getTypeIcon(response.type) }}</mat-icon>
85
+              </div>
86
+              <div class="history-content">
87
+                <div class="history-session">{{ response.sessionId | slice:0:6 }}...</div>
88
+                <div class="history-preview">{{ response.content | slice:0:60 }}...</div>
89
+              </div>
90
+              <div class="history-time">
91
+                {{ response.timestamp | date:'HH:mm' }}
92
+              </div>
93
+            </div>
94
+          }
95
+        }
96
+      </div>
97
+    </div>
98
+  }
99
+</div>

+ 383
- 0
src/app/components/ai-response.component.scss Ver fichero

@@ -0,0 +1,383 @@
1
+:host {
2
+  display: block;
3
+  height: 100%;
4
+}
5
+
6
+.ai-response-container {
7
+  display: flex;
8
+  flex-direction: column;
9
+  height: 100%;
10
+  background: var(--mat-app-background-color, #f5f5f5);
11
+  border-radius: 8px;
12
+  overflow: hidden;
13
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
14
+}
15
+
16
+/* 头部工具栏 */
17
+.header-toolbar {
18
+  display: flex;
19
+  justify-content: space-between;
20
+  align-items: center;
21
+  padding: 12px 16px;
22
+  background: white;
23
+  border-bottom: 1px solid var(--mat-divider-color, #e0e0e0);
24
+  min-height: 56px;
25
+  box-sizing: border-box;
26
+}
27
+
28
+.session-info {
29
+  display: flex;
30
+  align-items: center;
31
+  gap: 12px;
32
+  flex-wrap: wrap;
33
+}
34
+
35
+.type-indicator {
36
+  display: flex;
37
+  align-items: center;
38
+  gap: 6px;
39
+  padding: 4px 10px;
40
+  border-radius: 16px;
41
+  color: white;
42
+  font-size: 12px;
43
+  font-weight: 500;
44
+}
45
+
46
+.type-icon {
47
+  font-size: 16px;
48
+  width: 16px;
49
+  height: 16px;
50
+  line-height: 16px;
51
+}
52
+
53
+.type-label {
54
+  line-height: 1;
55
+}
56
+
57
+.session-id, .timestamp, .empty-state {
58
+  display: flex;
59
+  align-items: center;
60
+  gap: 6px;
61
+  font-size: 12px;
62
+  color: var(--mat-secondary-text-color, #666);
63
+}
64
+
65
+.empty-state {
66
+  font-size: 14px;
67
+  color: var(--mat-primary-text-color, #333);
68
+  
69
+  mat-icon {
70
+    color: var(--mat-primary-color, #3f51b5);
71
+  }
72
+}
73
+
74
+.toolbar-actions {
75
+  display: flex;
76
+  gap: 4px;
77
+}
78
+
79
+/* 内容区域 */
80
+.content-area {
81
+  flex: 1;
82
+  overflow: auto;
83
+  padding: 16px;
84
+  background: white;
85
+  transition: all 0.3s ease;
86
+  
87
+  &.streaming {
88
+    background: linear-gradient(135deg, #f9f9ff 0%, #f0f8ff 100%);
89
+  }
90
+  
91
+  &.collapsed {
92
+    max-height: 200px;
93
+    overflow: hidden;
94
+    position: relative;
95
+    
96
+    &::after {
97
+      content: '';
98
+      position: absolute;
99
+      bottom: 0;
100
+      left: 0;
101
+      right: 0;
102
+      height: 60px;
103
+      background: linear-gradient(to bottom, transparent, white);
104
+      pointer-events: none;
105
+    }
106
+  }
107
+}
108
+
109
+.content-wrapper {
110
+  min-height: 100%;
111
+}
112
+
113
+.response-content {
114
+  font-size: 14px;
115
+  line-height: 1.6;
116
+  color: var(--mat-primary-text-color, #333);
117
+  
118
+  // Markdown样式增强
119
+  ::ng-deep {
120
+    h1, h2, h3, h4 {
121
+      margin-top: 1.2em;
122
+      margin-bottom: 0.6em;
123
+      color: var(--mat-primary-color, #3f51b5);
124
+    }
125
+    
126
+    code {
127
+      background: var(--mat-app-background-color, #f5f5f5);
128
+      padding: 2px 6px;
129
+      border-radius: 4px;
130
+      font-family: 'Courier New', monospace;
131
+      font-size: 0.9em;
132
+    }
133
+    
134
+    pre {
135
+      background: #1e1e1e;
136
+      color: #d4d4d4;
137
+      padding: 16px;
138
+      border-radius: 6px;
139
+      overflow: auto;
140
+      margin: 1em 0;
141
+      
142
+      code {
143
+        background: transparent;
144
+        padding: 0;
145
+        border-radius: 0;
146
+        font-size: 0.9em;
147
+        line-height: 1.5;
148
+      }
149
+    }
150
+    
151
+    blockquote {
152
+      border-left: 4px solid var(--mat-primary-color, #3f51b5);
153
+      margin: 1em 0;
154
+      padding-left: 1em;
155
+      color: var(--mat-secondary-text-color, #666);
156
+      font-style: italic;
157
+    }
158
+    
159
+    ul, ol {
160
+      padding-left: 1.5em;
161
+      margin: 0.5em 0;
162
+    }
163
+    
164
+    table {
165
+      border-collapse: collapse;
166
+      width: 100%;
167
+      margin: 1em 0;
168
+      
169
+      th, td {
170
+        border: 1px solid var(--mat-divider-color, #e0e0e0);
171
+        padding: 8px 12px;
172
+        text-align: left;
173
+      }
174
+      
175
+      th {
176
+        background: var(--mat-app-background-color, #f5f5f5);
177
+        font-weight: 600;
178
+      }
179
+    }
180
+  }
181
+}
182
+
183
+.streaming-indicator {
184
+  margin-top: 20px;
185
+  padding: 12px;
186
+  background: rgba(33, 150, 243, 0.05);
187
+  border-radius: 8px;
188
+  border: 1px solid rgba(33, 150, 243, 0.2);
189
+  
190
+  .streaming-text {
191
+    display: flex;
192
+    align-items: center;
193
+    gap: 8px;
194
+    margin-top: 8px;
195
+    font-size: 12px;
196
+    color: var(--mat-primary-color, #3f51b5);
197
+    
198
+    mat-icon {
199
+      font-size: 16px;
200
+      width: 16px;
201
+      height: 16px;
202
+      line-height: 16px;
203
+      animation: pulse 1.5s infinite;
204
+    }
205
+  }
206
+}
207
+
208
+@keyframes pulse {
209
+  0%, 100% { opacity: 1; }
210
+  50% { opacity: 0.5; }
211
+}
212
+
213
+.response-complete {
214
+  display: flex;
215
+  align-items: center;
216
+  gap: 8px;
217
+  margin-top: 20px;
218
+  padding: 8px 12px;
219
+  background: rgba(76, 175, 80, 0.1);
220
+  border-radius: 8px;
221
+  color: #2e7d32;
222
+  font-size: 13px;
223
+  
224
+  mat-icon {
225
+    color: #2e7d32;
226
+    font-size: 16px;
227
+    width: 16px;
228
+    height: 16px;
229
+    line-height: 16px;
230
+  }
231
+}
232
+
233
+.no-response {
234
+  display: flex;
235
+  flex-direction: column;
236
+  align-items: center;
237
+  justify-content: center;
238
+  height: 100%;
239
+  color: var(--mat-secondary-text-color, #666);
240
+  text-align: center;
241
+  
242
+  .empty-icon {
243
+    font-size: 48px;
244
+    width: 48px;
245
+    height: 48px;
246
+    line-height: 48px;
247
+    margin-bottom: 16px;
248
+    color: var(--mat-primary-color, #3f51b5);
249
+    opacity: 0.3;
250
+  }
251
+  
252
+  .hint {
253
+    font-size: 12px;
254
+    margin-top: 4px;
255
+    color: var(--mat-secondary-text-color, #888);
256
+  }
257
+}
258
+
259
+/* 响应历史 */
260
+.response-history {
261
+  border-top: 1px solid var(--mat-divider-color, #e0e0e0);
262
+  background: var(--mat-app-background-color, #f5f5f5);
263
+  max-height: 200px;
264
+  overflow: auto;
265
+}
266
+
267
+.history-header {
268
+  display: flex;
269
+  align-items: center;
270
+  gap: 8px;
271
+  padding: 12px 16px;
272
+  font-size: 12px;
273
+  font-weight: 500;
274
+  color: var(--mat-secondary-text-color, #666);
275
+  background: rgba(0, 0, 0, 0.02);
276
+  border-bottom: 1px solid var(--mat-divider-color, #e0e0e0);
277
+}
278
+
279
+.history-list {
280
+  padding: 8px;
281
+}
282
+
283
+.history-item {
284
+  display: flex;
285
+  align-items: center;
286
+  gap: 10px;
287
+  padding: 8px 12px;
288
+  background: white;
289
+  border-radius: 6px;
290
+  margin-bottom: 6px;
291
+  cursor: pointer;
292
+  border-left: 3px solid;
293
+  transition: all 0.2s ease;
294
+  
295
+  &:hover {
296
+    background: var(--mat-app-background-color, #f5f5f5);
297
+    transform: translateX(2px);
298
+  }
299
+  
300
+  &:active {
301
+    transform: translateX(0);
302
+  }
303
+}
304
+
305
+.history-type-icon {
306
+  display: flex;
307
+  align-items: center;
308
+  justify-content: center;
309
+  width: 24px;
310
+  height: 24px;
311
+  border-radius: 50%;
312
+  background: currentColor;
313
+  opacity: 0.2;
314
+  
315
+  mat-icon {
316
+    font-size: 16px;
317
+    width: 16px;
318
+    height: 16px;
319
+    line-height: 16px;
320
+    color: white;
321
+  }
322
+}
323
+
324
+.history-content {
325
+  flex: 1;
326
+  overflow: hidden;
327
+}
328
+
329
+.history-session {
330
+  font-size: 10px;
331
+  color: var(--mat-secondary-text-color, #888);
332
+  margin-bottom: 2px;
333
+}
334
+
335
+.history-preview {
336
+  font-size: 11px;
337
+  color: var(--mat-primary-text-color, #444);
338
+  white-space: nowrap;
339
+  overflow: hidden;
340
+  text-overflow: ellipsis;
341
+}
342
+
343
+.history-time {
344
+  font-size: 10px;
345
+  color: var(--mat-secondary-text-color, #888);
346
+  white-space: nowrap;
347
+}
348
+
349
+/* 滚动条样式 */
350
+::-webkit-scrollbar {
351
+  width: 6px;
352
+  height: 6px;
353
+}
354
+
355
+::-webkit-scrollbar-track {
356
+  background: transparent;
357
+}
358
+
359
+::-webkit-scrollbar-thumb {
360
+  background: var(--mat-divider-color, #ccc);
361
+  border-radius: 3px;
362
+}
363
+
364
+::-webkit-scrollbar-thumb:hover {
365
+  background: var(--mat-secondary-text-color, #999);
366
+}
367
+
368
+/* 响应式设计 */
369
+@media (max-width: 768px) {
370
+  .header-toolbar {
371
+    flex-direction: column;
372
+    align-items: stretch;
373
+    gap: 8px;
374
+  }
375
+  
376
+  .session-info {
377
+    justify-content: space-between;
378
+  }
379
+  
380
+  .toolbar-actions {
381
+    align-self: flex-end;
382
+  }
383
+}

+ 279
- 0
src/app/components/ai-response.component.ts Ver fichero

@@ -0,0 +1,279 @@
1
+import { Component, OnInit, OnDestroy, Input, OnChanges, SimpleChanges } from '@angular/core';
2
+import { CommonModule } from '@angular/common';
3
+import { MatIcon } from '@angular/material/icon';
4
+import { MatCardModule } from '@angular/material/card';
5
+import { MatButtonModule } from '@angular/material/button';
6
+import { MatTooltipModule } from '@angular/material/tooltip';
7
+import { MatProgressBarModule } from '@angular/material/progress-bar';
8
+import { Subscription } from 'rxjs';
9
+import { EventService } from '../services/event.service';
10
+import { MarkdownPipe } from '../shared/pipes/markdown.pipe';
11
+
12
+export interface AIResponse {
13
+  sessionId: string;
14
+  content: string;
15
+  timestamp: Date;
16
+  type: 'thinking' | 'tool' | 'error' | 'message' | 'markdown';
17
+  isComplete: boolean;
18
+}
19
+
20
+@Component({
21
+  selector: 'app-ai-response',
22
+  standalone: true,
23
+  imports: [
24
+    CommonModule,
25
+    MatIcon,
26
+    MatCardModule,
27
+    MatButtonModule,
28
+    MatTooltipModule,
29
+    MatProgressBarModule,
30
+    MarkdownPipe
31
+  ],
32
+  templateUrl: './ai-response.component.html',
33
+  styleUrl: './ai-response.component.scss'
34
+})
35
+export class AIResponseComponent implements OnInit, OnDestroy, OnChanges {
36
+  @Input() sessionId?: string;
37
+  @Input() multipleSessionIds: string[] = [];
38
+  
39
+  currentResponse: AIResponse | null = null;
40
+  responses: AIResponse[] = [];
41
+  isStreaming = false;
42
+  streamingProgress = 0;
43
+  showFullText = true;
44
+  
45
+  private subscriptions: Subscription = new Subscription();
46
+  private typingInterval: any;
47
+  private typingContent = '';
48
+  private typingIndex = 0;
49
+  
50
+  constructor(private eventService: EventService) {}
51
+  
52
+  ngOnInit() {
53
+    this.setupEventListeners();
54
+  }
55
+  
56
+  ngOnChanges(changes: SimpleChanges) {
57
+    if (changes['sessionId'] || changes['multipleSessionIds']) {
58
+      this.resetResponse();
59
+      this.setupEventListeners();
60
+    }
61
+  }
62
+  
63
+  ngOnDestroy() {
64
+    this.subscriptions.unsubscribe();
65
+    this.clearTypingInterval();
66
+  }
67
+  
68
+  private setupEventListeners() {
69
+    // 清除现有订阅
70
+    this.subscriptions.unsubscribe();
71
+    this.subscriptions = new Subscription();
72
+    
73
+    if (this.multipleSessionIds && this.multipleSessionIds.length > 0) {
74
+      // 多会话模式 - 监听所有文档会话的事件
75
+      this.multipleSessionIds.forEach(sessionId => {
76
+        if (sessionId && sessionId.trim()) {
77
+          this.subscriptions.add(
78
+            this.eventService.subscribeToSessionEvents(sessionId).subscribe({
79
+              next: (event) => {
80
+                this.processEvent(sessionId, event);
81
+              },
82
+              error: (error) => console.error(`会话 ${sessionId} 事件监听错误:`, error)
83
+            })
84
+          );
85
+        }
86
+      });
87
+    } else if (this.sessionId) {
88
+      // 单会话模式 - 监听当前会话的事件
89
+      this.subscriptions.add(
90
+        this.eventService.subscribeToSessionEvents(this.sessionId!).subscribe({
91
+          next: (event) => {
92
+            this.processEvent(this.sessionId!, event);
93
+          },
94
+          error: (error) => console.error(`会话 ${this.sessionId!} 事件监听错误:`, error)
95
+        })
96
+      );
97
+    }
98
+  }
99
+  
100
+  private processEvent(sessionId: string, event: any) {
101
+    const payload = event.payload;
102
+    const timestamp = new Date();
103
+    
104
+    // 确定事件类型
105
+    let type: AIResponse['type'] = 'message';
106
+    if (payload.type === 'thinking' || payload.type === 'tool_call') {
107
+      type = 'thinking';
108
+    } else if (payload.type === 'tool' || payload.type === 'tool_result') {
109
+      type = 'tool';
110
+    } else if (payload.type === 'error') {
111
+      type = 'error';
112
+    } else if (payload.type === 'markdown' || payload.type?.includes('markdown')) {
113
+      type = 'markdown';
114
+    }
115
+    
116
+    // 提取内容
117
+    let content = '';
118
+    const properties = payload.properties || {};
119
+    
120
+    if (payload.type === 'message.updated') {
121
+      // 处理消息更新事件
122
+      const messageInfo = properties.info || {};
123
+      if (messageInfo.content) {
124
+        content = messageInfo.content;
125
+      }
126
+    } else if (properties.content) {
127
+      content = properties.content;
128
+    } else if (properties.message) {
129
+      content = properties.message;
130
+    } else if (properties.info && properties.info.content) {
131
+      content = properties.info.content;
132
+    } else {
133
+      // 如果无法提取,将整个payload转换为字符串
134
+      content = JSON.stringify(payload, null, 2);
135
+    }
136
+    
137
+    // 如果是思考或工具调用,可能需要特殊处理
138
+    if (type === 'thinking' || type === 'tool') {
139
+      // 这些内容可以折叠显示
140
+      content = content.replace(/^\[思考\]|^\[工具\]|^\[错误\]/, '').trim();
141
+    }
142
+    
143
+    const newResponse: AIResponse = {
144
+      sessionId,
145
+      content,
146
+      timestamp,
147
+      type,
148
+      isComplete: true
149
+    };
150
+    
151
+    // 添加到响应列表
152
+    this.responses.push(newResponse);
153
+    
154
+    // 如果是当前会话,开始打字机效果
155
+    if ((this.multipleSessionIds && this.multipleSessionIds.includes(sessionId)) || 
156
+        sessionId === this.sessionId) {
157
+      this.startTypingEffect(newResponse);
158
+    }
159
+    
160
+    // 限制响应数量,防止内存泄漏
161
+    if (this.responses.length > 100) {
162
+      this.responses = this.responses.slice(-50);
163
+    }
164
+  }
165
+  
166
+  private startTypingEffect(response: AIResponse) {
167
+    this.clearTypingInterval();
168
+    
169
+    this.currentResponse = response;
170
+    this.isStreaming = true;
171
+    this.streamingProgress = 0;
172
+    this.typingContent = response.content;
173
+    this.typingIndex = 0;
174
+    
175
+    const totalChars = this.typingContent.length;
176
+    if (totalChars === 0) {
177
+      this.isStreaming = false;
178
+      return;
179
+    }
180
+    
181
+    // 计算打字速度(每秒50个字符)
182
+    const speed = 50; // 字符/秒
183
+    const interval = 1000 / speed;
184
+    
185
+    this.typingInterval = setInterval(() => {
186
+      this.typingIndex++;
187
+      this.streamingProgress = (this.typingIndex / totalChars) * 100;
188
+      
189
+      if (this.typingIndex >= totalChars) {
190
+        this.isStreaming = false;
191
+        this.clearTypingInterval();
192
+      }
193
+    }, interval);
194
+  }
195
+  
196
+  private clearTypingInterval() {
197
+    if (this.typingInterval) {
198
+      clearInterval(this.typingInterval);
199
+      this.typingInterval = null;
200
+    }
201
+  }
202
+  
203
+  getDisplayContent(): string {
204
+    if (!this.currentResponse) return '';
205
+    
206
+    if (this.isStreaming) {
207
+      return this.typingContent.substring(0, this.typingIndex);
208
+    }
209
+    
210
+    return this.currentResponse.content;
211
+  }
212
+  
213
+  getCurrentType(): AIResponse['type'] {
214
+    return this.currentResponse?.type || 'message';
215
+  }
216
+  
217
+  getCurrentSessionId(): string {
218
+    return this.currentResponse?.sessionId || '';
219
+  }
220
+  
221
+  getFormattedTimestamp(): string {
222
+    if (!this.currentResponse?.timestamp) return '';
223
+    
224
+    const date = this.currentResponse.timestamp;
225
+    return date.toLocaleTimeString('zh-CN', { 
226
+      hour: '2-digit', 
227
+      minute: '2-digit',
228
+      second: '2-digit'
229
+    });
230
+  }
231
+  
232
+  toggleFullText() {
233
+    this.showFullText = !this.showFullText;
234
+  }
235
+  
236
+  clearResponses() {
237
+    this.responses = [];
238
+    this.currentResponse = null;
239
+    this.isStreaming = false;
240
+    this.streamingProgress = 0;
241
+  }
242
+  
243
+  private resetResponse() {
244
+    this.currentResponse = null;
245
+    this.isStreaming = false;
246
+    this.streamingProgress = 0;
247
+    this.clearTypingInterval();
248
+  }
249
+  
250
+  getTypeIcon(type: AIResponse['type']): string {
251
+    switch (type) {
252
+      case 'thinking': return 'psychology';
253
+      case 'tool': return 'build';
254
+      case 'error': return 'error';
255
+      case 'markdown': return 'code';
256
+      default: return 'chat';
257
+    }
258
+  }
259
+  
260
+  getTypeColor(type: AIResponse['type']): string {
261
+    switch (type) {
262
+      case 'thinking': return 'var(--thinking-color, #2196f3)';
263
+      case 'tool': return 'var(--tool-color, #ff9800)';
264
+      case 'error': return 'var(--error-color, #f44336)';
265
+      case 'markdown': return 'var(--markdown-color, #673ab7)';
266
+      default: return 'var(--message-color, #4caf50)';
267
+    }
268
+  }
269
+  
270
+  getTypeLabel(type: AIResponse['type']): string {
271
+    switch (type) {
272
+      case 'thinking': return '思考中';
273
+      case 'tool': return '工具调用';
274
+      case 'error': return '错误';
275
+      case 'markdown': return '代码';
276
+      default: return '消息';
277
+    }
278
+  }
279
+}

+ 1
- 1
src/app/components/conversation.component.html Ver fichero

@@ -1,6 +1,6 @@
1 1
     <div class="conversation-container">
2 2
       <!-- 消息区域 -->
3
-      <div class="messages-container" #messagesContainer>
3
+      <div class="messages-container" #messagesContainer (scroll)="onMessagesContainerScroll()">
4 4
         <div *ngFor="let message of messages" class="message-wrapper">
5 5
           <div class="message" [class.user]="message.role === 'user'" [class.assistant]="message.role === 'assistant'">
6 6
             <div class="message-header">

+ 34
- 17
src/app/components/conversation.component.scss Ver fichero

@@ -15,23 +15,24 @@
15 15
       margin-bottom: 20px;
16 16
     }
17 17
     .message {
18
-      max-width: 80%;
18
+      max-width: 95%;
19 19
       padding: 12px 16px;
20 20
       border-radius: 18px;
21 21
       position: relative;
22
+      margin-bottom: 12px;
22 23
     }
23 24
     .message.user {
24
-      background: #007bff;
25
+      background: linear-gradient(135deg, #007bff, #0056b3);
25 26
       color: white;
26
-      margin-left: auto;
27
-      border-bottom-right-radius: 4px;
27
+      border-left: 4px solid #0056b3;
28
+      box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2);
28 29
     }
29 30
     .message.assistant {
30
-      background: white;
31
+      background: linear-gradient(135deg, #ffffff, #f8f9fa);
31 32
       color: #333;
32 33
       border: 1px solid #e0e0e0;
33
-      margin-right: auto;
34
-      border-bottom-left-radius: 4px;
34
+      border-left: 4px solid #6c757d;
35
+      box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
35 36
     }
36 37
     .message-header {
37 38
       display: flex;
@@ -108,13 +109,17 @@
108 109
     }
109 110
 
110 111
     .thinking-details {
111
-      border: 1px solid #e3f2fd;
112
-      background-color: #f5fbff;
112
+      border: 2px solid #2196f3;
113
+      background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
114
+      border-radius: 8px;
115
+      box-shadow: 0 2px 8px rgba(33, 150, 243, 0.2);
113 116
     }
114 117
 
115 118
     .tool-details {
116
-      border: 1px solid #f3e5f5;
117
-      background-color: #faf5ff;
119
+      border: 2px solid #9c27b0;
120
+      background: linear-gradient(135deg, #f3e5f5 0%, #e1bee7 100%);
121
+      border-radius: 8px;
122
+      box-shadow: 0 2px 8px rgba(156, 39, 176, 0.2);
118 123
     }
119 124
 
120 125
     .thinking-summary,
@@ -129,13 +134,17 @@
129 134
     }
130 135
 
131 136
     .thinking-summary {
132
-      color: #1565c0;
133
-      background-color: #e3f2fd;
137
+      color: #0d47a1;
138
+      background: linear-gradient(135deg, #bbdefb 0%, #90caf9 100%);
139
+      border-bottom: 1px solid #64b5f6;
140
+      font-weight: 600;
134 141
     }
135 142
 
136 143
     .tool-summary {
137
-      color: #7b1fa2;
138
-      background-color: #f3e5f5;
144
+      color: #4a148c;
145
+      background: linear-gradient(135deg, #e1bee7 0%, #ce93d8 100%);
146
+      border-bottom: 1px solid #ba68c8;
147
+      font-weight: 600;
139 148
     }
140 149
 
141 150
     /* details元素内的内容样式 */
@@ -146,11 +155,19 @@
146 155
     }
147 156
 
148 157
     .thinking-details > *:not(summary) {
149
-      background-color: #f5fbff;
158
+      background-color: #f0f7ff;
159
+      color: #0d47a1;
160
+      font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
161
+      font-size: 0.95em;
162
+      line-height: 1.5;
150 163
     }
151 164
 
152 165
     .tool-details > *:not(summary) {
153
-      background-color: #faf5ff;
166
+      background-color: #f5f0ff;
167
+      color: #4a148c;
168
+      font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
169
+      font-size: 0.95em;
170
+      line-height: 1.5;
154 171
     }
155 172
 
156 173
     /* 标签样式(非折叠) */

+ 24
- 1
src/app/components/conversation.component.ts Ver fichero

@@ -49,6 +49,8 @@ export class ConversationComponent implements OnInit, OnDestroy, AfterViewChecke
49 49
   private currentStreamSubscription: Subscription | null = null;
50 50
   private sessionEventSubscription: Subscription | null = null;
51 51
   private shouldScroll = false;
52
+  private isUserScrolling = false;
53
+  private scrollDebounceTimeout: any = null;
52 54
   private isInThinking = false; // 是否正在思考过程中
53 55
   private thinkingStartIndex = 0; // 思考内容的起始位置(在消息内容中的索引)
54 56
   
@@ -284,7 +286,7 @@ export class ConversationComponent implements OnInit, OnDestroy, AfterViewChecke
284 286
   }
285 287
 
286 288
   ngAfterViewChecked() {
287
-    if (this.shouldScroll) {
289
+    if (this.shouldScroll && !this.isUserScrolling) {
288 290
       this.scrollToBottom();
289 291
       this.shouldScroll = false;
290 292
     }
@@ -482,6 +484,12 @@ export class ConversationComponent implements OnInit, OnDestroy, AfterViewChecke
482 484
     // 添加用户消息
483 485
     this.messages.push(userMessage);
484 486
     this.shouldScroll = true;
487
+    // 用户发送新消息时重置手动滚动标志
488
+    this.isUserScrolling = false;
489
+    if (this.scrollDebounceTimeout) {
490
+      clearTimeout(this.scrollDebounceTimeout);
491
+      this.scrollDebounceTimeout = null;
492
+    }
485 493
 
486 494
     // 添加AI响应占位
487 495
     const aiMessage: ChatMessage = {
@@ -534,6 +542,21 @@ export class ConversationComponent implements OnInit, OnDestroy, AfterViewChecke
534 542
 
535 543
 
536 544
 
545
+  onMessagesContainerScroll() {
546
+    // 用户手动滚动时触发
547
+    this.isUserScrolling = true;
548
+    
549
+    // 清除之前的超时
550
+    if (this.scrollDebounceTimeout) {
551
+      clearTimeout(this.scrollDebounceTimeout);
552
+    }
553
+    
554
+    // 设置超时,2秒后重置滚动标志
555
+    this.scrollDebounceTimeout = setTimeout(() => {
556
+      this.isUserScrolling = false;
557
+    }, 2000);
558
+  }
559
+
537 560
   private scrollToBottom() {
538 561
     try {
539 562
       this.messagesContainer.nativeElement.scrollTop = 

+ 74
- 0
src/app/components/document-list.component.html Ver fichero

@@ -0,0 +1,74 @@
1
+<div class="document-list-container">
2
+  <div class="document-list-header">
3
+    <mat-icon class="header-icon">folder_open</mat-icon>
4
+    <h3 class="header-title">项目文档</h3>
5
+  </div>
6
+  
7
+  <div class="document-items">
8
+    @for (docType of documentTypeOrder; track docType) {
9
+      @let doc = getDocumentByType(docType);
10
+      @if (doc) {
11
+        <div class="document-item" [class.has-content]="hasContent(doc)" [class.loading]="doc.isLoading">
12
+          <!-- 文档标题区域 -->
13
+          <div class="document-header" (click)="toggleDocument(docType)">
14
+            <div class="document-icon-title">
15
+              <mat-icon class="document-icon">{{ getIcon(docType) }}</mat-icon>
16
+              <span class="document-title">{{ getDisplayName(docType) }}</span>
17
+              @if (doc.isLoading) {
18
+                <mat-spinner diameter="16" class="document-loading-spinner"></mat-spinner>
19
+              }
20
+            </div>
21
+            
22
+            <div class="document-actions">
23
+              @if (hasContent(doc)) {
24
+                <span class="document-updated" matTooltip="最后更新时间">
25
+                  {{ getLastUpdatedText(doc) }}
26
+                </span>
27
+              }
28
+              <mat-icon class="expand-icon">
29
+                {{ expandedStates[docType] ? 'expand_less' : 'expand_more' }}
30
+              </mat-icon>
31
+            </div>
32
+          </div>
33
+          
34
+          <!-- 文档内容区域(可折叠) -->
35
+          @if (expandedStates[docType]) {
36
+            <div class="document-content">
37
+              @if (hasContent(doc)) {
38
+                <div class="document-content-inner" [innerHTML]="doc.content | markdown"></div>
39
+              } @else {
40
+                <div class="document-empty">
41
+                  <mat-icon>inbox</mat-icon>
42
+                  <p>暂无内容</p>
43
+                  @if (doc.isLoading) {
44
+                    <p class="loading-text">正在加载中...</p>
45
+                  }
46
+                </div>
47
+              }
48
+            </div>
49
+          } @else {
50
+            <!-- 折叠状态下的预览 -->
51
+            @if (hasContent(doc)) {
52
+              <div class="document-preview">
53
+                <span class="preview-text">{{ getContentPreview(doc.content) }}</span>
54
+              </div>
55
+            }
56
+          }
57
+        </div>
58
+      } @else {
59
+        <!-- 文档不存在的情况 -->
60
+        <div class="document-item empty">
61
+          <div class="document-header">
62
+            <div class="document-icon-title">
63
+              <mat-icon class="document-icon">{{ getIcon(docType) }}</mat-icon>
64
+              <span class="document-title">{{ getDisplayName(docType) }}</span>
65
+            </div>
66
+            <div class="document-status">
67
+              <span class="status-text">未创建</span>
68
+            </div>
69
+          </div>
70
+        </div>
71
+      }
72
+    }
73
+  </div>
74
+</div>

+ 244
- 0
src/app/components/document-list.component.scss Ver fichero

@@ -0,0 +1,244 @@
1
+.document-list-container {
2
+  display: flex;
3
+  flex-direction: column;
4
+  height: 100%;
5
+  background: #f8f9fa;
6
+}
7
+
8
+.document-list-header {
9
+  display: flex;
10
+  align-items: center;
11
+  gap: 8px;
12
+  padding: 12px 16px;
13
+  border-bottom: 1px solid #e0e0e0;
14
+  background: #ffffff;
15
+  flex-shrink: 0;
16
+  
17
+  .header-icon {
18
+    color: #1976d2;
19
+  }
20
+  
21
+  .header-title {
22
+    margin: 0;
23
+    font-size: 16px;
24
+    font-weight: 500;
25
+    color: #333;
26
+  }
27
+}
28
+
29
+.document-items {
30
+  flex: 1;
31
+  overflow-y: auto;
32
+  padding: 8px;
33
+}
34
+
35
+.document-item {
36
+  background: #ffffff;
37
+  border-radius: 8px;
38
+  margin-bottom: 8px;
39
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
40
+  transition: all 0.2s ease;
41
+  
42
+  &:hover {
43
+    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
44
+  }
45
+  
46
+  &.has-content {
47
+    border-left: 4px solid #1976d2;
48
+  }
49
+  
50
+  &.loading {
51
+    opacity: 0.8;
52
+  }
53
+  
54
+  &.empty {
55
+    opacity: 0.6;
56
+    border-left: 4px solid #ccc;
57
+  }
58
+}
59
+
60
+.document-header {
61
+  display: flex;
62
+  align-items: center;
63
+  justify-content: space-between;
64
+  padding: 12px 16px;
65
+  cursor: pointer;
66
+  user-select: none;
67
+  min-height: 48px;
68
+  
69
+  &:hover {
70
+    background-color: #f5f5f5;
71
+    border-radius: 8px 8px 0 0;
72
+  }
73
+}
74
+
75
+.document-icon-title {
76
+  display: flex;
77
+  align-items: center;
78
+  gap: 8px;
79
+  flex: 1;
80
+  min-width: 0;
81
+  
82
+  .document-icon {
83
+    color: #666;
84
+    font-size: 20px;
85
+    height: 20px;
86
+    width: 20px;
87
+    flex-shrink: 0;
88
+  }
89
+  
90
+  .document-title {
91
+    font-size: 14px;
92
+    font-weight: 500;
93
+    color: #333;
94
+    white-space: nowrap;
95
+    overflow: hidden;
96
+    text-overflow: ellipsis;
97
+  }
98
+  
99
+  .document-loading-spinner {
100
+    margin-left: 8px;
101
+  }
102
+}
103
+
104
+.document-actions {
105
+  display: flex;
106
+  align-items: center;
107
+  gap: 8px;
108
+  flex-shrink: 0;
109
+  
110
+  .document-updated {
111
+    font-size: 12px;
112
+    color: #888;
113
+    white-space: nowrap;
114
+  }
115
+  
116
+  .expand-icon {
117
+    color: #999;
118
+    font-size: 20px;
119
+    height: 20px;
120
+    width: 20px;
121
+    transition: transform 0.2s ease;
122
+  }
123
+}
124
+
125
+.document-content {
126
+  padding: 16px;
127
+  border-top: 1px solid #f0f0f0;
128
+  background: #ffffff;
129
+  border-radius: 0 0 8px 8px;
130
+  max-height: 400px;
131
+  overflow-y: auto;
132
+  
133
+  .document-content-inner {
134
+    font-size: 13px;
135
+    line-height: 1.6;
136
+    color: #444;
137
+    
138
+    // Markdown内容样式
139
+    :deep(h1), :deep(h2), :deep(h3) {
140
+      margin-top: 0;
141
+      margin-bottom: 12px;
142
+      font-weight: 600;
143
+    }
144
+    
145
+    :deep(h1) { font-size: 16px; }
146
+    :deep(h2) { font-size: 15px; }
147
+    :deep(h3) { font-size: 14px; }
148
+    
149
+    :deep(p) {
150
+      margin: 8px 0;
151
+    }
152
+    
153
+    :deep(ul), :deep(ol) {
154
+      padding-left: 20px;
155
+      margin: 8px 0;
156
+    }
157
+    
158
+    :deep(li) {
159
+      margin: 4px 0;
160
+    }
161
+    
162
+    :deep(code) {
163
+      background: #f5f5f5;
164
+      padding: 2px 4px;
165
+      border-radius: 3px;
166
+      font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
167
+      font-size: 12px;
168
+    }
169
+    
170
+    :deep(pre) {
171
+      background: #f5f5f5;
172
+      padding: 12px;
173
+      border-radius: 6px;
174
+      overflow-x: auto;
175
+      margin: 12px 0;
176
+    }
177
+  }
178
+}
179
+
180
+.document-empty {
181
+  display: flex;
182
+  flex-direction: column;
183
+  align-items: center;
184
+  justify-content: center;
185
+  padding: 32px 16px;
186
+  color: #999;
187
+  text-align: center;
188
+  
189
+  mat-icon {
190
+    font-size: 32px;
191
+    height: 32px;
192
+    width: 32px;
193
+    margin-bottom: 12px;
194
+    color: #ccc;
195
+  }
196
+  
197
+  p {
198
+    margin: 4px 0;
199
+    font-size: 14px;
200
+  }
201
+  
202
+  .loading-text {
203
+    color: #666;
204
+    font-size: 12px;
205
+  }
206
+}
207
+
208
+.document-preview {
209
+  padding: 8px 16px 12px 16px;
210
+  border-top: 1px solid #f0f0f0;
211
+  background: #fafafa;
212
+  border-radius: 0 0 8px 8px;
213
+  
214
+  .preview-text {
215
+    font-size: 12px;
216
+    color: #666;
217
+    line-height: 1.4;
218
+    display: -webkit-box;
219
+    -webkit-line-clamp: 2;
220
+    -webkit-box-orient: vertical;
221
+    overflow: hidden;
222
+  }
223
+}
224
+
225
+.document-status {
226
+  .status-text {
227
+    font-size: 12px;
228
+    color: #999;
229
+    font-style: italic;
230
+  }
231
+}
232
+
233
+// 响应式调整
234
+@media (max-height: 700px) {
235
+  .document-content {
236
+    max-height: 300px;
237
+  }
238
+}
239
+
240
+@media (max-height: 600px) {
241
+  .document-content {
242
+    max-height: 250px;
243
+  }
244
+}

+ 122
- 0
src/app/components/document-list.component.ts Ver fichero

@@ -0,0 +1,122 @@
1
+import { Component, Input, OnInit } from '@angular/core';
2
+import { CommonModule } from '@angular/common';
3
+import { MatIconModule } from '@angular/material/icon';
4
+import { MatExpansionModule } from '@angular/material/expansion';
5
+import { MatButtonModule } from '@angular/material/button';
6
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
7
+import { MatTooltipModule } from '@angular/material/tooltip';
8
+import { DocumentSession, DocumentType, DocumentTypeDisplayName, DocumentTypeIcon, getDocumentTypeOrder } from '../models/document.model';
9
+import { MarkdownPipe } from '../shared/pipes/markdown.pipe';
10
+
11
+@Component({
12
+  selector: 'app-document-list',
13
+  standalone: true,
14
+  imports: [
15
+    CommonModule,
16
+    MatIconModule,
17
+    MatExpansionModule,
18
+    MatButtonModule,
19
+    MatProgressSpinnerModule,
20
+    MatTooltipModule,
21
+    MarkdownPipe
22
+  ],
23
+  templateUrl: './document-list.component.html',
24
+  styleUrl: './document-list.component.scss'
25
+})
26
+export class DocumentListComponent implements OnInit {
27
+  @Input() documents: DocumentSession[] = [];
28
+  @Input() defaultExpanded: boolean = false;
29
+  
30
+  // 按固定顺序排序的文档类型
31
+  documentTypeOrder = getDocumentTypeOrder();
32
+  
33
+  // 控制每个文档的展开状态
34
+  expandedStates: Record<DocumentType, boolean> = {
35
+    [DocumentType.UserRequirement]: false,
36
+    [DocumentType.Requirement]: false,
37
+    [DocumentType.Technical]: false,
38
+    [DocumentType.Implementation]: false,
39
+    [DocumentType.Test]: false
40
+  };
41
+  
42
+  ngOnInit() {
43
+    // 初始化展开状态
44
+    if (this.defaultExpanded) {
45
+      this.documentTypeOrder.forEach(docType => {
46
+        this.expandedStates[docType] = true;
47
+      });
48
+    }
49
+  }
50
+  
51
+  /**
52
+   * 获取指定类型的文档
53
+   */
54
+  getDocumentByType(docType: DocumentType): DocumentSession | undefined {
55
+    return this.documents.find(doc => doc.type === docType);
56
+  }
57
+  
58
+  /**
59
+   * 切换文档的展开状态
60
+   */
61
+  toggleDocument(docType: DocumentType) {
62
+    this.expandedStates[docType] = !this.expandedStates[docType];
63
+  }
64
+  
65
+  /**
66
+   * 获取文档显示名称
67
+   */
68
+  getDisplayName(docType: DocumentType): string {
69
+    return DocumentTypeDisplayName[docType];
70
+  }
71
+  
72
+  /**
73
+   * 获取文档图标
74
+   */
75
+  getIcon(docType: DocumentType): string {
76
+    return DocumentTypeIcon[docType];
77
+  }
78
+  
79
+  /**
80
+   * 检查文档是否有内容
81
+   */
82
+  hasContent(doc: DocumentSession): boolean {
83
+    return !!doc.hasContent && !!doc.content && doc.content.trim().length > 0;
84
+  }
85
+  
86
+  /**
87
+   * 获取文档最后更新时间显示文本
88
+   */
89
+  getLastUpdatedText(doc: DocumentSession): string {
90
+    if (!doc.lastUpdated) return '';
91
+    
92
+    const now = new Date();
93
+    const diffMs = now.getTime() - doc.lastUpdated.getTime();
94
+    const diffMins = Math.floor(diffMs / (1000 * 60));
95
+    const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
96
+    const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
97
+    
98
+    if (diffMins < 60) {
99
+      return `${diffMins}分钟前`;
100
+    } else if (diffHours < 24) {
101
+      return `${diffHours}小时前`;
102
+    } else {
103
+      return `${diffDays}天前`;
104
+    }
105
+  }
106
+  
107
+  /**
108
+   * 格式化内容长度,用于预览
109
+   */
110
+  getContentPreview(content: string): string {
111
+    if (!content) return '暂无内容';
112
+    
113
+    const maxLength = 100;
114
+    const plainText = content.replace(/[#\*`]/g, '').replace(/\n/g, ' ');
115
+    
116
+    if (plainText.length <= maxLength) {
117
+      return plainText;
118
+    }
119
+    
120
+    return plainText.substring(0, maxLength) + '...';
121
+  }
122
+}

+ 31
- 6
src/app/components/project-tab.component.html Ver fichero

@@ -1,4 +1,4 @@
1
-<div class="project-tab-container" [style.gridTemplateColumns]="leftWidth + 'px 8px 1fr'">
1
+<div class="project-tab-container" [style.gridTemplateColumns]="leftWidth + 'px 8px ' + middleWidth + 'px 8px 1fr'">
2 2
   <!-- 左侧面板:项目信息和步骤列表 -->
3 3
   <div class="left-panel">
4 4
     @if (project) {
@@ -163,7 +163,15 @@
163 163
             <div class="step-status">待完成</div>
164 164
           </div>
165 165
         </mat-expansion-panel>
166
-      </mat-accordion>
166
+       </mat-accordion>
167
+       
168
+       <!-- 文档列表 -->
169
+       <div class="document-list-section">
170
+         <app-document-list 
171
+           [documents]="documentSessions"
172
+           [defaultExpanded]="false">
173
+         </app-document-list>
174
+       </div>
167 175
     } @else {
168 176
       <div class="no-project">
169 177
         <mat-icon>error</mat-icon>
@@ -172,17 +180,34 @@
172 180
     }
173 181
   </div>
174 182
   
175
-  <!-- 分割线(调整左侧宽度) -->
176
-  <div class="splitter"
183
+  <!-- 左侧分割线(调整左侧宽度) -->
184
+  <div class="splitter left-splitter"
177 185
        (mousedown)="startDrag($event)"
178 186
        [class.dragging]="isDragging">
179 187
     <div class="splitter-handle"></div>
180 188
   </div>
181 189
   
182
-  <!-- 右侧面板:对话区域 -->
183
-  <div class="right-panel">
190
+  <!-- 中间面板:对话区域 -->
191
+  <div class="middle-panel">
184 192
     <div class="conversation-wrapper">
185 193
       <app-conversation [instanceId]="project?.id"></app-conversation>
186 194
     </div>
187 195
   </div>
196
+  
197
+  <!-- 中间分割线(调整中间宽度) -->
198
+  <div class="splitter middle-splitter"
199
+       (mousedown)="startMiddleDrag($event)"
200
+       [class.dragging]="isDraggingMiddle">
201
+    <div class="splitter-handle"></div>
202
+  </div>
203
+  
204
+  <!-- 右侧面板:AI回复显示框 -->
205
+  <div class="right-panel">
206
+    <div class="ai-response-wrapper">
207
+      <app-ai-response
208
+        [multipleSessionIds]="getDocumentSessionIds()"
209
+        [sessionId]="project?.id">
210
+      </app-ai-response>
211
+    </div>
212
+  </div>
188 213
 </div>

+ 54
- 3
src/app/components/project-tab.component.scss Ver fichero

@@ -4,7 +4,7 @@
4 4
   overflow: hidden;
5 5
 }
6 6
 
7
-.left-panel, .right-panel {
7
+.left-panel, .middle-panel, .right-panel {
8 8
   height: 100%;
9 9
   overflow-y: auto;
10 10
   padding: 16px;
@@ -15,10 +15,17 @@
15 15
   display: flex;
16 16
   flex-direction: column;
17 17
   gap: 16px;
18
+  min-height: 0;
18 19
 }
19 20
 
20
-.right-panel {
21
+.middle-panel {
21 22
   background: #ffffff;
23
+  border-left: 1px solid #e0e0e0;
24
+  border-right: 1px solid #e0e0e0;
25
+}
26
+
27
+.right-panel {
28
+  background: #fafafa;
22 29
 }
23 30
 
24 31
 .project-info-card {
@@ -113,9 +120,10 @@
113 120
 }
114 121
 
115 122
 .steps-accordion {
116
-  flex: 1;
123
+  flex: 0;
117 124
   min-height: 0;
118 125
   overflow-y: auto;
126
+  max-height: 300px;
119 127
 }
120 128
 
121 129
 .step-content {
@@ -169,4 +177,47 @@
169 177
 .conversation-wrapper {
170 178
   height: 100%;
171 179
   overflow: hidden;
180
+}
181
+
182
+.ai-response-wrapper {
183
+  height: 100%;
184
+  display: flex;
185
+  flex-direction: column;
186
+}
187
+
188
+.placeholder {
189
+  flex: 1;
190
+  display: flex;
191
+  flex-direction: column;
192
+  align-items: center;
193
+  justify-content: center;
194
+  color: #999;
195
+  text-align: center;
196
+  padding: 40px 20px;
197
+}
198
+
199
+.placeholder mat-icon {
200
+  font-size: 48px;
201
+  height: 48px;
202
+  width: 48px;
203
+  margin-bottom: 16px;
204
+  color: #ccc;
205
+}
206
+
207
+.placeholder p {
208
+  font-size: 14px;
209
+  color: #888;
210
+}
211
+
212
+.document-list-section {
213
+  margin-top: 16px;
214
+  flex: 1;
215
+  min-height: 0;
216
+  display: flex;
217
+  flex-direction: column;
218
+  
219
+  app-document-list {
220
+    flex: 1;
221
+    min-height: 0;
222
+  }
172 223
 }

+ 169
- 4
src/app/components/project-tab.component.ts Ver fichero

@@ -11,10 +11,15 @@ import { MatProgressBarModule } from '@angular/material/progress-bar';
11 11
 import { MatTooltipModule } from '@angular/material/tooltip';
12 12
 import { Subscription } from 'rxjs';
13 13
 import { ConversationComponent } from './conversation.component';
14
+import { DocumentListComponent } from './document-list.component';
15
+import { AIResponseComponent } from './ai-response.component';
14 16
 import { TabService, Tab } from '../services/tab.service';
15 17
 import { SessionService } from '../services/session.service';
16 18
 import { AgentService } from '../services/agent.service';
19
+import { MockDataService } from '../services/mock-data.service';
20
+import { EventService } from '../services/event.service';
17 21
 import { Session, SessionStatus } from '../models/session.model';
22
+import { DocumentType, DocumentSession } from '../models/document.model';
18 23
 
19 24
 @Component({
20 25
   selector: 'app-project-tab',
@@ -29,7 +34,9 @@ import { Session, SessionStatus } from '../models/session.model';
29 34
     MatChipsModule,
30 35
     MatProgressBarModule,
31 36
     MatTooltipModule,
32
-    ConversationComponent
37
+    ConversationComponent,
38
+    DocumentListComponent,
39
+    AIResponseComponent
33 40
   ],
34 41
   templateUrl: './project-tab.component.html',
35 42
   styleUrl: './project-tab.component.scss',
@@ -40,25 +47,39 @@ export class ProjectTabComponent implements OnInit, OnDestroy {
40 47
   project: Session | null = null;
41 48
   activeTab: Tab | null = null;
42 49
   
50
+  // 文档数据
51
+  documentSessions: DocumentSession[] = [];
52
+  
43 53
   leftWidth = 320;
54
+  middleWidth = 500;
44 55
   isDragging = false;
56
+  isDraggingMiddle = false;
45 57
   minLeftWidth = 280;
46 58
   maxLeftWidth = 500;
59
+  minMiddleWidth = 400;
60
+  maxMiddleWidth = 800;
47 61
   
48 62
   private subscriptions: Subscription = new Subscription();
63
+  private eventSubscription?: Subscription;
49 64
   
50 65
   constructor(
51 66
     private tabService: TabService,
52 67
     private sessionService: SessionService,
53 68
     private agentService: AgentService,
69
+    private mockDataService: MockDataService,
70
+    private eventService: EventService,
54 71
     private route: ActivatedRoute
55 72
   ) {}
56 73
   
57 74
   ngOnInit() {
58 75
     // 从本地存储恢复宽度
59
-    const savedWidth = localStorage.getItem('project_tab_leftWidth');
60
-    if (savedWidth) {
61
-      this.leftWidth = parseInt(savedWidth, 10);
76
+    const savedLeftWidth = localStorage.getItem('project_tab_leftWidth');
77
+    if (savedLeftWidth) {
78
+      this.leftWidth = parseInt(savedLeftWidth, 10);
79
+    }
80
+    const savedMiddleWidth = localStorage.getItem('project_tab_middleWidth');
81
+    if (savedMiddleWidth) {
82
+      this.middleWidth = parseInt(savedMiddleWidth, 10);
62 83
     }
63 84
     
64 85
     // 监听路由参数变化
@@ -92,8 +113,12 @@ export class ProjectTabComponent implements OnInit, OnDestroy {
92 113
         this.activeTab = tab;
93 114
         this.project = tab.session;
94 115
         this.sessionService.setActiveSession(this.project);
116
+        this.loadDocumentSessions();
95 117
       }
96 118
     }
119
+    
120
+    // 加载文档数据
121
+    this.loadDocumentSessions();
97 122
   }
98 123
   
99 124
   private loadTabByProjectId(projectId: string) {
@@ -178,6 +203,13 @@ export class ProjectTabComponent implements OnInit, OnDestroy {
178 203
     document.body.style.userSelect = 'none';
179 204
   }
180 205
   
206
+  startMiddleDrag(event: MouseEvent) {
207
+    event.preventDefault();
208
+    this.isDraggingMiddle = true;
209
+    document.body.style.cursor = 'col-resize';
210
+    document.body.style.userSelect = 'none';
211
+  }
212
+  
181 213
   @HostListener('document:mousemove', ['$event'])
182 214
   onDrag(event: MouseEvent) {
183 215
     if (this.isDragging) {
@@ -186,6 +218,12 @@ export class ProjectTabComponent implements OnInit, OnDestroy {
186 218
         this.leftWidth = newWidth;
187 219
       }
188 220
     }
221
+    if (this.isDraggingMiddle) {
222
+      const newWidth = event.clientX - this.leftWidth - 8;
223
+      if (newWidth >= this.minMiddleWidth && newWidth <= this.maxMiddleWidth) {
224
+        this.middleWidth = newWidth;
225
+      }
226
+    }
189 227
   }
190 228
   
191 229
   @HostListener('document:mouseup')
@@ -194,11 +232,138 @@ export class ProjectTabComponent implements OnInit, OnDestroy {
194 232
       this.isDragging = false;
195 233
       localStorage.setItem('project_tab_leftWidth', this.leftWidth.toString());
196 234
     }
235
+    if (this.isDraggingMiddle) {
236
+      this.isDraggingMiddle = false;
237
+      localStorage.setItem('project_tab_middleWidth', this.middleWidth.toString());
238
+    }
197 239
     document.body.style.cursor = '';
198 240
     document.body.style.userSelect = '';
199 241
   }
200 242
   
243
+  /**
244
+   * 加载文档会话数据
245
+   */
246
+  private loadDocumentSessions() {
247
+    if (!this.project) return;
248
+    
249
+    const projectId = this.project.project_id || this.project.id;
250
+    this.mockDataService.getProjectDocuments(projectId).subscribe({
251
+      next: (documents) => {
252
+        this.documentSessions = documents;
253
+        
254
+        // 设置文档会话的事件订阅
255
+        this.setupDocumentEventSubscriptions();
256
+      },
257
+      error: (error) => {
258
+        console.error('加载文档数据失败:', error);
259
+        // 使用空文档作为回退
260
+        import('../models/document.model').then(module => {
261
+          this.documentSessions = module.createEmptyDocumentSessions(projectId);
262
+          // 即使使用空文档,也尝试设置事件订阅
263
+          this.setupDocumentEventSubscriptions();
264
+        });
265
+      }
266
+    });
267
+  }
268
+  
269
+  /**
270
+   * 设置文档会话的事件订阅
271
+   */
272
+  private setupDocumentEventSubscriptions() {
273
+    // 取消现有的事件订阅
274
+    if (this.eventSubscription) {
275
+      this.eventSubscription.unsubscribe();
276
+      this.eventSubscription = undefined;
277
+    }
278
+    
279
+    // 获取有效的文档会话ID
280
+    const sessionIds = this.getDocumentSessionIds();
281
+    if (sessionIds.length === 0) {
282
+      console.log('没有有效的文档会话ID可订阅');
283
+      return;
284
+    }
285
+    
286
+    console.log(`设置文档事件订阅,会话ID:`, sessionIds);
287
+    
288
+    // 连接到多会话事件流
289
+    this.eventService.connectToMultipleSessions(sessionIds);
290
+    
291
+    // 订阅多会话事件
292
+    this.eventSubscription = this.eventService.multiSessionEvents$.subscribe({
293
+      next: ({ sessionId, event }) => {
294
+        this.handleDocumentEvent(sessionId, event);
295
+      },
296
+      error: (error) => {
297
+        console.error('文档事件订阅错误:', error);
298
+      }
299
+    });
300
+  }
301
+  
302
+  /**
303
+   * 处理文档事件,更新文档内容
304
+   */
305
+  private handleDocumentEvent(sessionId: string, event: any) {
306
+    // 找到对应的文档
307
+    const docIndex = this.documentSessions.findIndex(doc => doc.sessionId === sessionId);
308
+    if (docIndex === -1) {
309
+      // 未找到对应文档,可能是其他事件
310
+      return;
311
+    }
312
+    
313
+    const payload = event.payload;
314
+    console.log(`处理文档事件,会话ID: ${sessionId}, 类型: ${payload.type}`);
315
+    
316
+    // 提取内容
317
+    let content = '';
318
+    const properties = payload.properties || {};
319
+    
320
+    if (payload.type === 'message.updated') {
321
+      // 处理消息更新事件
322
+      const messageInfo = properties.info || {};
323
+      if (messageInfo.content) {
324
+        content = messageInfo.content;
325
+      }
326
+    } else if (properties.content) {
327
+      content = properties.content;
328
+    } else if (properties.message) {
329
+      content = properties.message;
330
+    } else if (properties.info && properties.info.content) {
331
+      content = properties.info.content;
332
+    }
333
+    
334
+    if (content) {
335
+      // 更新文档内容
336
+      this.documentSessions[docIndex] = {
337
+        ...this.documentSessions[docIndex],
338
+        content: content,
339
+        lastUpdated: new Date(),
340
+        hasContent: true,
341
+        isLoading: false
342
+      };
343
+      
344
+      // 触发变更检测(使用展开操作符创建新数组)
345
+      this.documentSessions = [...this.documentSessions];
346
+      
347
+      console.log(`文档 ${this.documentSessions[docIndex].title} 内容已更新,长度: ${content.length} 字符`);
348
+    }
349
+  }
350
+  
351
+  /**
352
+   * 获取所有文档会话ID(过滤空值)
353
+   */
354
+  getDocumentSessionIds(): string[] {
355
+    return this.documentSessions
356
+      .map(doc => doc.sessionId)
357
+      .filter(sessionId => sessionId && sessionId.trim() !== '');
358
+  }
359
+  
201 360
   ngOnDestroy() {
202 361
     this.subscriptions.unsubscribe();
362
+    
363
+    // 取消文档事件订阅
364
+    if (this.eventSubscription) {
365
+      this.eventSubscription.unsubscribe();
366
+      this.eventSubscription = undefined;
367
+    }
203 368
   }
204 369
 }

+ 85
- 0
src/app/models/document.model.ts Ver fichero

@@ -0,0 +1,85 @@
1
+// 文档类型枚举
2
+export enum DocumentType {
3
+  UserRequirement = 'user_requirement',    // 用户原始需求
4
+  Requirement = 'requirement',             // 需求文档
5
+  Technical = 'technical',                 // 技术文档
6
+  Implementation = 'implementation',       // 业务实现
7
+  Test = 'test'                           // 测试文档
8
+}
9
+
10
+// 文档类型显示名称映射
11
+export const DocumentTypeDisplayName: Record<DocumentType, string> = {
12
+  [DocumentType.UserRequirement]: '用户原始需求',
13
+  [DocumentType.Requirement]: '需求文档',
14
+  [DocumentType.Technical]: '技术文档',
15
+  [DocumentType.Implementation]: '业务实现',
16
+  [DocumentType.Test]: '测试文档'
17
+};
18
+
19
+// 文档类型图标映射
20
+export const DocumentTypeIcon: Record<DocumentType, string> = {
21
+  [DocumentType.UserRequirement]: 'description',
22
+  [DocumentType.Requirement]: 'assignment',
23
+  [DocumentType.Technical]: 'code',
24
+  [DocumentType.Implementation]: 'architecture',
25
+  [DocumentType.Test]: 'bug_report'
26
+};
27
+
28
+// 文档会话信息
29
+export interface DocumentSession {
30
+  id: string;                           // 文档唯一ID (DocumentType值)
31
+  sessionId: string;                    // 对应会话ID
32
+  title: string;                        // 文档标题 (显示名称)
33
+  content: string;                      // 从SSE获取的内容
34
+  type: DocumentType;                   // 文档类型
35
+  lastUpdated: Date;                    // 最后更新时间
36
+  isLoading?: boolean;                  // 是否正在加载
37
+  hasContent?: boolean;                 // 是否有内容
38
+}
39
+
40
+// 项目文档会话映射
41
+export interface ProjectDocumentSessions {
42
+  projectId: string;                    // 项目ID
43
+  documents: {
44
+    [DocumentType.UserRequirement]?: string;  // 用户原始需求会话ID
45
+    [DocumentType.Requirement]?: string;      // 需求文档会话ID
46
+    [DocumentType.Technical]?: string;        // 技术文档会话ID
47
+    [DocumentType.Implementation]?: string;   // 业务实现会话ID
48
+    [DocumentType.Test]?: string;             // 测试文档会话ID
49
+  };
50
+}
51
+
52
+// 获取文档类型顺序(按照显示顺序)
53
+export function getDocumentTypeOrder(): DocumentType[] {
54
+  return [
55
+    DocumentType.UserRequirement,
56
+    DocumentType.Requirement,
57
+    DocumentType.Technical,
58
+    DocumentType.Implementation,
59
+    DocumentType.Test
60
+  ];
61
+}
62
+
63
+// 从会话ID获取文档类型
64
+export function getDocumentTypeFromSessionId(sessionId: string, projectDocs: ProjectDocumentSessions): DocumentType | null {
65
+  for (const docType of getDocumentTypeOrder()) {
66
+    if (projectDocs.documents[docType] === sessionId) {
67
+      return docType;
68
+    }
69
+  }
70
+  return null;
71
+}
72
+
73
+// 创建空的文档会话数组(用于初始化)
74
+export function createEmptyDocumentSessions(projectId: string): DocumentSession[] {
75
+  return getDocumentTypeOrder().map(docType => ({
76
+    id: docType,
77
+    sessionId: '',
78
+    title: DocumentTypeDisplayName[docType],
79
+    content: '',
80
+    type: docType,
81
+    lastUpdated: new Date(),
82
+    isLoading: false,
83
+    hasContent: false
84
+  }));
85
+}

+ 4
- 0
src/app/models/instance.model.ts Ver fichero

@@ -1,10 +1,14 @@
1
+import { DocumentType } from './document.model';
2
+
1 3
 // 实例类型
2 4
 export interface Instance {
3 5
   id: string; // 实例唯一ID
4 6
   menuItemId: string; // 关联的菜单项ID
5 7
   title: string; // 实例标题(通常为菜单项名称)
6 8
   sessionIds: string[]; // 该实例关联的会话ID列表
9
+  documentSessionIds: string[]; // 5个文档会话ID列表
7 10
   activeSessionId?: string; // 当前活动的会话ID
11
+  activeDocumentType?: DocumentType; // 当前活动的文档类型
8 12
   createdAt: Date;
9 13
 }
10 14
 

+ 138
- 0
src/app/services/event.service.ts Ver fichero

@@ -48,6 +48,13 @@ export class EventService implements OnDestroy {
48 48
   // 会话ID到实例ID的注册映射
49 49
   private sessionToInstanceMap = new Map<string, string>();
50 50
   
51
+  // 多会话事件源映射(用于文档列表等场景)
52
+  private multiSessionEventSources = new Map<string, EventSource>();
53
+  
54
+  // 多会话事件主题
55
+  private multiSessionEventsSubject = new Subject<{sessionId: string, event: GlobalEvent}>();
56
+  multiSessionEvents$ = this.multiSessionEventsSubject.asObservable();
57
+  
51 58
   constructor(
52 59
     private authService: AuthService,
53 60
     @Optional() private instanceService?: InstanceService,
@@ -173,6 +180,137 @@ export class EventService implements OnDestroy {
173 180
     };
174 181
   }
175 182
   
183
+  /**
184
+   * 连接到多会话事件流(用于文档列表等场景)
185
+   * @param sessionIds 会话ID数组
186
+   */
187
+  connectToMultipleSessions(sessionIds: string[]) {
188
+    if (!sessionIds || sessionIds.length === 0) {
189
+      return;
190
+    }
191
+    
192
+    // 检查是否已登录
193
+    if (!this.authService.isAuthenticated()) {
194
+      console.warn('未登录状态,不连接多会话事件流');
195
+      return;
196
+    }
197
+    
198
+    // 获取认证token
199
+    const token = this.authService.getToken();
200
+    if (!token) {
201
+      console.error('无法获取认证token');
202
+      return;
203
+    }
204
+    
205
+    // 为每个会话ID创建独立的EventSource连接
206
+    sessionIds.forEach(sessionId => {
207
+      // 如果已经存在连接,先关闭
208
+      if (this.multiSessionEventSources.has(sessionId)) {
209
+        const existingSource = this.multiSessionEventSources.get(sessionId);
210
+        if (existingSource) {
211
+          existingSource.close();
212
+        }
213
+        this.multiSessionEventSources.delete(sessionId);
214
+      }
215
+      
216
+      // 构建带认证参数的URL
217
+      const url = `/api/logs/stream?token=${encodeURIComponent(token)}&sessionId=${sessionId}`;
218
+      console.log(`EventService: 连接多会话事件流,会话ID: ${sessionId}, URL: ${url}`);
219
+      
220
+      const eventSource = new EventSource(url);
221
+      this.multiSessionEventSources.set(sessionId, eventSource);
222
+      
223
+      eventSource.onopen = () => {
224
+        console.log(`EventService: 多会话事件流连接已建立,会话ID: ${sessionId}`);
225
+      };
226
+      
227
+      eventSource.onmessage = (event) => {
228
+        const eventData = event.data;
229
+        
230
+        // 跳过心跳消息
231
+        if (eventData === ': heartbeat' || eventData.startsWith(': ')) {
232
+          return;
233
+        }
234
+        
235
+        try {
236
+          // 解析JSON事件
237
+          const globalEvent: GlobalEvent = JSON.parse(eventData);
238
+          console.log(`EventService: 收到多会话事件,会话ID: ${sessionId}, 类型: ${globalEvent.payload.type}`);
239
+          
240
+          // 分发到多会话事件主题
241
+          this.multiSessionEventsSubject.next({ sessionId, event: globalEvent });
242
+          
243
+          // 同时分发到全局事件流(保持兼容性)
244
+          this.distributeEventBySession(globalEvent);
245
+        } catch (error) {
246
+          console.error(`EventService: 解析多会话事件失败,会话ID: ${sessionId}, 数据:`, eventData.substring(0, 100));
247
+        }
248
+      };
249
+      
250
+      eventSource.onerror = (error) => {
251
+        console.error(`EventService: 多会话事件流连接错误,会话ID: ${sessionId}:`, error);
252
+        // 多会话连接错误不触发全局重连,只关闭该连接
253
+        eventSource.close();
254
+        this.multiSessionEventSources.delete(sessionId);
255
+        
256
+        // 可选:重新连接单个会话(延迟重试)
257
+        setTimeout(() => {
258
+          if (this.authService.isAuthenticated()) {
259
+            this.connectToMultipleSessions([sessionId]);
260
+          }
261
+        }, 5000);
262
+      };
263
+    });
264
+  }
265
+  
266
+  /**
267
+   * 断开指定会话的多会话事件流
268
+   * @param sessionIds 会话ID数组(如果为空则断开所有)
269
+   */
270
+  disconnectMultipleSessions(sessionIds?: string[]) {
271
+    if (!sessionIds) {
272
+      // 断开所有多会话连接
273
+      this.multiSessionEventSources.forEach((eventSource, sessionId) => {
274
+        eventSource.close();
275
+        console.log(`EventService: 断开多会话事件流,会话ID: ${sessionId}`);
276
+      });
277
+      this.multiSessionEventSources.clear();
278
+    } else {
279
+      // 断开指定会话的连接
280
+      sessionIds.forEach(sessionId => {
281
+        const eventSource = this.multiSessionEventSources.get(sessionId);
282
+        if (eventSource) {
283
+          eventSource.close();
284
+          this.multiSessionEventSources.delete(sessionId);
285
+          console.log(`EventService: 断开多会话事件流,会话ID: ${sessionId}`);
286
+        }
287
+      });
288
+    }
289
+  }
290
+  
291
+  /**
292
+   * 获取指定会话的事件流(按会话ID过滤)
293
+   * @param sessionId 会话ID
294
+   * @returns 过滤后的事件Observable
295
+   */
296
+  getSessionEvents(sessionId: string): Observable<GlobalEvent> {
297
+    return this.sessionEvents$.pipe(
298
+      filter(({ sessionId: eventSessionId }) => eventSessionId === sessionId),
299
+      map(({ event }) => event)
300
+    );
301
+  }
302
+  
303
+  /**
304
+   * 获取多个会话的事件流(按会话ID数组过滤)
305
+   * @param sessionIds 会话ID数组
306
+   * @returns 过滤后的事件Observable
307
+   */
308
+  getMultipleSessionEvents(sessionIds: string[]): Observable<{sessionId: string, event: GlobalEvent}> {
309
+    return this.multiSessionEvents$.pipe(
310
+      filter(({ sessionId }) => sessionIds.includes(sessionId))
311
+    );
312
+  }
313
+  
176 314
   // 处理全局事件
177 315
   private handleGlobalEvent(event: GlobalEvent) {
178 316
     console.log('EventService: 处理全局事件,类型:', event.payload.type);

+ 1
- 0
src/app/services/instance.service.ts Ver fichero

@@ -57,6 +57,7 @@ export class InstanceService {
57 57
       menuItemId: params.menuItemId,
58 58
       title: params.title || menuItem.name,
59 59
       sessionIds: [],
60
+      documentSessionIds: [], // 初始化为空数组
60 61
       activeSessionId: undefined,
61 62
       createdAt: new Date()
62 63
     };

+ 373
- 0
src/app/services/mock-data.service.ts Ver fichero

@@ -0,0 +1,373 @@
1
+import { Injectable } from '@angular/core';
2
+import { Observable, of } from 'rxjs';
3
+import { DocumentSession, DocumentType, createEmptyDocumentSessions } from '../models/document.model';
4
+
5
+@Injectable({
6
+  providedIn: 'root'
7
+})
8
+export class MockDataService {
9
+  // 模拟项目ID
10
+  private mockProjectId = 'proj_mock_001';
11
+  
12
+  // 模拟文档会话数据
13
+  private mockDocumentSessions: DocumentSession[] = [
14
+    {
15
+      id: DocumentType.UserRequirement,
16
+      sessionId: 'session_user_req_001',
17
+      title: '用户原始需求',
18
+      content: `帮我建立自动补货:要求先按销售业绩进行店铺分组,商品按爆畅平滞分类,后,按照售罄率进行补货等等来编写sql代码,分步骤建立临时表保存过度数据,最后测试通过,发布。
19
+
20
+具体要求:
21
+1. 店铺分组:按最近30天销售额分为A、B、C三类店铺
22
+2. 商品分类:按销售表现分为爆款、畅款、平款、滞销款
23
+3. 补货逻辑:基于售罄率计算补货数量
24
+4. 临时表:每个步骤建立临时表保存中间结果
25
+5. 最终输出:完整的补货建议报告`,
26
+      type: DocumentType.UserRequirement,
27
+      lastUpdated: new Date('2025-02-13T10:00:00'),
28
+      isLoading: false,
29
+      hasContent: true
30
+    },
31
+    {
32
+      id: DocumentType.Requirement,
33
+      sessionId: 'session_req_002',
34
+      title: '需求文档',
35
+      content: `# 自动补货系统需求文档
36
+
37
+## 1. 业务目标
38
+建立智能补货系统,基于销售数据和库存情况自动生成补货建议。
39
+
40
+## 2. 核心功能
41
+### 2.1 店铺分级
42
+- 输入:店铺最近30天销售数据
43
+- 逻辑:按销售额排序,分为A(前30%)、B(中间40%)、C(后30%)
44
+- 输出:店铺分级表
45
+
46
+### 2.2 商品分类
47
+- 输入:商品销售数据(销量、销售额、库存)
48
+- 逻辑:按销售表现分类:
49
+  - 爆款:销量Top 20%,售罄率>80%
50
+  - 畅款:销量中间60%,售罄率40-80%
51
+  - 平款:销量较低但稳定
52
+  - 滞销款:30天无销售或售罄率<20%
53
+- 输出:商品分类表
54
+
55
+### 2.3 补货计算
56
+- 输入:商品分类、店铺分级、当前库存、销售预测
57
+- 逻辑:基于售罄率计算建议补货数量
58
+- 公式:建议补货量 = (销售预测 × 补货系数) - 当前库存
59
+- 输出:补货建议表
60
+
61
+## 3. 数据流程
62
+1. 原始销售数据 → 店铺分级
63
+2. 商品销售数据 → 商品分类
64
+3. 分级+分类+库存 → 补货计算
65
+4. 输出补货建议报告`,
66
+      type: DocumentType.Requirement,
67
+      lastUpdated: new Date('2025-02-13T11:30:00'),
68
+      isLoading: false,
69
+      hasContent: true
70
+    },
71
+    {
72
+      id: DocumentType.Technical,
73
+      sessionId: 'session_tech_003',
74
+      title: '技术文档',
75
+      content: `# 自动补货系统技术方案
76
+
77
+## 1. 系统架构
78
+### 1.1 数据层
79
+- 数据源:销售事实表、店铺维度表、商品维度表
80
+- 存储:MySQL + Redis缓存
81
+- ETL:每日定时任务更新数据
82
+
83
+### 1.2 计算层
84
+- 临时表设计:
85
+  \`\`\`sql
86
+  -- 临时表1:店铺分级
87
+  CREATE TEMPORARY TABLE tmp_store_level AS
88
+  SELECT 
89
+    store_id,
90
+    sales_amount,
91
+    CASE 
92
+      WHEN sales_percentile <= 0.3 THEN 'A'
93
+      WHEN sales_percentile <= 0.7 THEN 'B'
94
+      ELSE 'C'
95
+    END AS store_level
96
+  FROM (
97
+    SELECT 
98
+      store_id,
99
+      SUM(sales_amount) as sales_amount,
100
+      PERCENT_RANK() OVER (ORDER BY SUM(sales_amount)) as sales_percentile
101
+    FROM sales_fact
102
+    WHERE sale_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
103
+    GROUP BY store_id
104
+  ) t;
105
+  \`\`\`
106
+
107
+### 1.3 业务层
108
+- 补货算法实现
109
+- 参数配置管理
110
+- 结果输出接口
111
+
112
+## 2. 关键技术
113
+- SQL窗口函数(PERCENT_RANK)
114
+- 临时表管理
115
+- 参数化查询
116
+- 批量数据处理`,
117
+      type: DocumentType.Technical,
118
+      lastUpdated: new Date('2025-02-13T13:15:00'),
119
+      isLoading: false,
120
+      hasContent: true
121
+    },
122
+    {
123
+      id: DocumentType.Implementation,
124
+      sessionId: 'session_impl_004',
125
+      title: '业务实现',
126
+      content: `# 自动补货SQL实现代码
127
+
128
+## 步骤1:店铺分级
129
+\`\`\`sql
130
+-- 创建店铺分级临时表
131
+DROP TEMPORARY TABLE IF EXISTS tmp_store_level;
132
+CREATE TEMPORARY TABLE tmp_store_level AS
133
+WITH store_sales AS (
134
+  SELECT 
135
+    s.store_id,
136
+    st.store_name,
137
+    SUM(f.sales_amount) as total_sales,
138
+    COUNT(DISTINCT f.sale_date) as sales_days,
139
+    ROW_NUMBER() OVER (ORDER BY SUM(f.sales_amount) DESC) as rank,
140
+    COUNT(*) OVER () as total_stores
141
+  FROM sales_fact f
142
+  JOIN store_dim s ON f.store_id = s.store_id
143
+  WHERE f.sale_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
144
+  GROUP BY s.store_id, st.store_name
145
+)
146
+SELECT 
147
+  store_id,
148
+  store_name,
149
+  total_sales,
150
+  sales_days,
151
+  CASE 
152
+    WHEN rank <= total_stores * 0.3 THEN 'A'
153
+    WHEN rank <= total_stores * 0.7 THEN 'B'
154
+    ELSE 'C'
155
+  END as store_level
156
+FROM store_sales;
157
+\`\`\`
158
+
159
+## 步骤2:商品分类
160
+\`\`\`sql
161
+-- 创建商品分类临时表
162
+DROP TEMPORARY TABLE IF EXISTS tmp_product_category;
163
+CREATE TEMPORARY TABLE tmp_product_category AS
164
+SELECT 
165
+  p.product_id,
166
+  p.product_name,
167
+  SUM(f.quantity) as total_sales_qty,
168
+  SUM(f.sales_amount) as total_sales_amount,
169
+  AVG(p.current_stock) as avg_stock,
170
+  CASE
171
+    WHEN SUM(f.quantity) > PERCENTILE_CONT(0.8) WITHIN GROUP (ORDER BY SUM(f.quantity)) OVER () 
172
+         AND (SUM(f.quantity) / (AVG(p.current_stock) + SUM(f.quantity))) > 0.8 
173
+         THEN '爆款'
174
+    WHEN SUM(f.quantity) > PERCENTILE_CONT(0.2) WITHIN GROUP (ORDER BY SUM(f.quantity)) OVER () 
175
+         THEN '畅款'
176
+    WHEN SUM(f.quantity) > 0 THEN '平款'
177
+    ELSE '滞销款'
178
+  END as product_category
179
+FROM sales_fact f
180
+JOIN product_dim p ON f.product_id = p.product_id
181
+WHERE f.sale_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
182
+GROUP BY p.product_id, p.product_name;
183
+\`\`\`
184
+
185
+## 步骤3:补货计算
186
+\`\`\`sql
187
+-- 最终补货建议
188
+SELECT 
189
+  s.store_id,
190
+  s.store_name,
191
+  s.store_level,
192
+  p.product_id,
193
+  p.product_name,
194
+  p.product_category,
195
+  p.current_stock,
196
+  f.avg_daily_sales,
197
+  ROUND(f.avg_daily_sales * 7 - p.current_stock, 0) as suggested_replenishment
198
+FROM store_dim s
199
+CROSS JOIN product_dim p
200
+JOIN (
201
+  SELECT 
202
+    store_id,
203
+    product_id,
204
+    AVG(quantity) as avg_daily_sales
205
+  FROM sales_fact
206
+  WHERE sale_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
207
+  GROUP BY store_id, product_id
208
+) f ON s.store_id = f.store_id AND p.product_id = f.product_id
209
+WHERE p.current_stock < f.avg_daily_sales * 3  -- 库存小于3天销量
210
+ORDER BY s.store_level, p.product_category, suggested_replenishment DESC;
211
+\`\`\``,
212
+      type: DocumentType.Implementation,
213
+      lastUpdated: new Date('2025-02-13T14:45:00'),
214
+      isLoading: false,
215
+      hasContent: true
216
+    },
217
+    {
218
+      id: DocumentType.Test,
219
+      sessionId: 'session_test_005',
220
+      title: '测试文档',
221
+      content: `# 自动补货系统测试方案
222
+
223
+## 1. 测试数据准备
224
+### 1.1 模拟数据
225
+\`\`\`sql
226
+-- 创建测试店铺数据
227
+INSERT INTO store_dim (store_id, store_name, region, create_time) VALUES
228
+('S001', '测试店铺A', '华东', NOW()),
229
+('S002', '测试店铺B', '华南', NOW()),
230
+('S003', '测试店铺C', '华北', NOW());
231
+
232
+-- 创建测试商品数据
233
+INSERT INTO product_dim (product_id, product_name, category, current_stock, create_time) VALUES
234
+('P001', '测试商品1', '服装', 100, NOW()),
235
+('P002', '测试商品2', '鞋类', 50, NOW()),
236
+('P003', '测试商品3', '配件', 200, NOW());
237
+
238
+-- 创建测试销售数据
239
+INSERT INTO sales_fact (sale_id, store_id, product_id, sale_date, quantity, sales_amount) VALUES
240
+(UUID(), 'S001', 'P001', DATE_SUB(CURDATE(), INTERVAL 1 DAY), 10, 1000),
241
+(UUID(), 'S001', 'P002', DATE_SUB(CURDATE(), INTERVAL 2 DAY), 5, 750),
242
+(UUID(), 'S002', 'P001', DATE_SUB(CURDATE(), INTERVAL 3 DAY), 8, 800);
243
+\`\`\`
244
+
245
+## 2. 功能测试用例
246
+### 2.1 店铺分级测试
247
+- 输入:30天销售数据
248
+- 预期:正确分为A、B、C三级
249
+- 验证:检查分级逻辑和比例
250
+
251
+### 2.2 商品分类测试
252
+- 输入:商品销售数据
253
+- 预期:正确分为爆、畅、平、滞四类
254
+- 验证:检查分类算法准确性
255
+
256
+### 2.3 补货计算测试
257
+- 输入:分级+分类+库存
258
+- 预期:合理的补货建议
259
+- 验证:检查计算公式和阈值
260
+
261
+## 3. 性能测试
262
+- 数据量:1000店铺 × 10000商品
263
+- 响应时间:< 30秒
264
+- 内存使用:< 2GB
265
+
266
+## 4. 集成测试
267
+- 数据流:源系统 → 临时表 → 结果表
268
+- 异常处理:空数据、异常值、连接中断
269
+- 恢复机制:失败重试、数据回滚`,
270
+      type: DocumentType.Test,
271
+      lastUpdated: new Date('2025-02-13T16:00:00'),
272
+      isLoading: false,
273
+      hasContent: true
274
+    }
275
+  ];
276
+
277
+  constructor() { }
278
+
279
+  /**
280
+   * 获取项目文档会话数据
281
+   */
282
+  getProjectDocuments(projectId: string): Observable<DocumentSession[]> {
283
+    if (projectId === this.mockProjectId) {
284
+      return of(this.mockDocumentSessions);
285
+    }
286
+    // 其他项目返回空数据
287
+    return of(createEmptyDocumentSessions(projectId));
288
+  }
289
+
290
+  /**
291
+   * 获取项目文档会话映射
292
+   */
293
+  getProjectDocumentSessionIds(projectId: string): Observable<Record<DocumentType, string>> {
294
+    const sessionIds: Record<DocumentType, string> = {
295
+      [DocumentType.UserRequirement]: 'session_user_req_001',
296
+      [DocumentType.Requirement]: 'session_req_002',
297
+      [DocumentType.Technical]: 'session_tech_003',
298
+      [DocumentType.Implementation]: 'session_impl_004',
299
+      [DocumentType.Test]: 'session_test_005'
300
+    };
301
+    
302
+    return of(sessionIds);
303
+  }
304
+
305
+  /**
306
+   * 模拟SSE事件流
307
+   * 在实际中将被真实的EventService替换
308
+   */
309
+  simulateSSEEvent(sessionId: string, content: string): Observable<string> {
310
+    // 模拟打字机效果
311
+    return new Observable<string>(observer => {
312
+      let index = 0;
313
+      const interval = setInterval(() => {
314
+        if (index < content.length) {
315
+          observer.next(content.charAt(index));
316
+          index++;
317
+        } else {
318
+          clearInterval(interval);
319
+          observer.complete();
320
+        }
321
+      }, 50);
322
+    });
323
+  }
324
+
325
+  /**
326
+   * 获取模拟AI回复
327
+   */
328
+  getMockAIResponse(): string {
329
+    return `# 自动补货系统实现建议
330
+
331
+我已经分析了您的需求,以下是完整的实现方案:
332
+
333
+## 1. 架构设计
334
+采用**分层临时表**策略,确保数据处理流程清晰:
335
+
336
+1. **店铺分级表** (tmp_store_level)
337
+2. **商品分类表** (tmp_product_category)  
338
+3. **销售预测表** (tmp_sales_forecast)
339
+4. **补货建议表** (tmp_replenishment_suggestion)
340
+
341
+## 2. 关键技术点
342
+
343
+### 2.1 窗口函数应用
344
+\`\`\`sql
345
+-- 使用PERCENT_RANK进行店铺分级
346
+PERCENT_RANK() OVER (ORDER BY sales_amount) as sales_percentile
347
+\`\`\`
348
+
349
+### 2.2 临时表管理
350
+\`\`\`sql
351
+-- 创建临时表保存中间结果
352
+CREATE TEMPORARY TABLE tmp_intermediate AS ...
353
+\`\`\`
354
+
355
+### 2.3 性能优化
356
+- 索引优化:在store_id, product_id, sale_date上建立索引
357
+- 分区策略:按日期分区销售事实表
358
+- 批量处理:分批次处理大数据量
359
+
360
+## 3. 完整实现代码
361
+[详见业务实现文档]
362
+
363
+## 4. 测试建议
364
+1. 单元测试:每个临时表独立测试
365
+2. 集成测试:完整流程测试
366
+3. 性能测试:大数据量压力测试
367
+
368
+## 5. 部署建议
369
+1. 生产环境:每日凌晨执行
370
+2. 监控:执行时间、数据质量
371
+3. 报警:异常情况及时通知`;
372
+  }
373
+}

+ 1
- 1
src/app/shared/pipes/markdown.pipe.ts Ver fichero

@@ -81,7 +81,7 @@ export class MarkdownPipe implements PipeTransform {
81 81
         return `<details class="${cssClass}-details"><summary class="${cssClass}-summary">${emoji} ${tagName}</summary></details>`;
82 82
       }
83 83
       
84
-      return `<details class="${cssClass}-details" ${cssClass === 'thinking' || cssClass === 'tool' ? '' : 'open'}><summary class="${cssClass}-summary">${emoji} ${tagName}</summary>${trimmedContent}</details>`;
84
+      return `<details class="${cssClass}-details" ${cssClass === 'thinking' || cssClass === 'tool' ? 'open' : ''}><summary class="${cssClass}-summary">${emoji} ${tagName}</summary>${trimmedContent}</details>`;
85 85
     });
86 86
   }
87 87
 

Loading…
Cancelar
Guardar