feat(frontend): integrate chatbox with real Hermes SSE backend
This commit is contained in:
parent
ec392b31bb
commit
b8a541dcea
344
docs/SPEC_chatbox_context_integration.md
Normal file
344
docs/SPEC_chatbox_context_integration.md
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
# SPEC: ChatBox 上下文接入与 UI 美化
|
||||||
|
|
||||||
|
> **版本**: v1.0
|
||||||
|
> **前置依赖**: 后端 `POST /deepview/chat` 和 `GET /deepview/chat/history` 已就绪
|
||||||
|
> **状态**: 草案 (Proposal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 问题陈述
|
||||||
|
|
||||||
|
当前 ChatBox 存在两大核心缺陷:
|
||||||
|
|
||||||
|
### 1.1 上下文接入是假的(Mock 硬编码)
|
||||||
|
打开 `app.ts` 的 `openChatSheet()` 和 `sendChatMessage()` 可知:
|
||||||
|
- 打招呼消息**硬编码**为"李女士(铂金会员/建档3年)"——与当前报告完全无关。
|
||||||
|
- 用户发消息后,回复也是**硬编码**(永远返回"连续2次拒绝乔雅登"那段话)。
|
||||||
|
- 没有调用任何后端 API (`POST /deepview/chat`),也没有通过 SSE 接收真实 AI 回复。
|
||||||
|
- ChatBox 的 `chatId` 没有与当前报告的 `reportId` 或 `contextId` 绑定,意味着**所有页面共享同一个假对话**。
|
||||||
|
|
||||||
|
### 1.2 ChatBox UI 缺乏角色区分
|
||||||
|
当前的消息气泡 `.msg-bubble` 仅通过左对齐/右对齐和背景色做了最基础的区分,没有:
|
||||||
|
- AI 头像(应为方形,体现机器人身份)
|
||||||
|
- 用户头像(应为圆形,复用 TopBar 的 `avatar-circle` 风格)
|
||||||
|
- 消息中 Markdown 的富文本渲染(AI 回复通常包含列表、加粗等)
|
||||||
|
|
||||||
|
### 1.3 ChatBox Header 没有指明上下文来源
|
||||||
|
Header 固定写死"💬 X光片追踪深打":
|
||||||
|
- 不知道当前追问的是**哪一份录音报告**,还是**哪位客户的全景档案**。
|
||||||
|
- 用户无法确认 AI 的回答是否基于正确的上下文。
|
||||||
|
|
||||||
|
### 1.4 但后端 SSE 桥接层已经完备!(Xinzong 审计交叉验证)
|
||||||
|
|
||||||
|
经与馨总智能体的 [SSE 事件路由全链路审计](file:///Users/lidongfang/.gemini/antigravity/brain/c489626d-a001-4aff-9e29-13dec0e6ffd4/xinzong_sse_event_routing_audit.md.resolved) 进行交叉比对,确认 **Deepview 后端已完整实现了与 Xinzong 相同的 Hermes SSE 桥接层**(见 `deepview_sse.py` 文件头 L12-19 的翻译规则声明):
|
||||||
|
|
||||||
|
| Hermes 原生回调 | Deepview SSE 事件 | 实现位置 | 状态 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `stream_delta_callback(text)` | `agent:chunk {chatId, text}` | `_makeStreamDeltaCallback` L216-233 | ✅ 已实现 |
|
||||||
|
| `stream_delta_callback(None)` | **丢弃** | L225-226 | ✅ 正确过滤 |
|
||||||
|
| `tool_progress("tool.started")` | `agent:thinking {chatId, step, message}` | `_makeToolProgressCallback` L246-257 | ✅ 已实现 |
|
||||||
|
| `tool_progress("tool.completed")` | **丢弃** | L258 | ✅ 正确过滤 |
|
||||||
|
| `tool_progress("reasoning.available")` | **丢弃** | L258 | ✅ 正确过滤(防重复渲染) |
|
||||||
|
| `run_conversation()` 返回 | `agent:done {chatId, fullAnswer}` | `_runChat` L627-630 | ✅ 已实现 |
|
||||||
|
| `run_conversation()` 异常 | `agent:error {chatId, message}` | `_runChat` L634-636 | ✅ 已实现 |
|
||||||
|
|
||||||
|
**关键结论**:Deepview 的 SSE 管线与 Xinzong 是**同源同构**的。
|
||||||
|
- 翻译规则完全一致(丢弃 `None`、丢弃 `tool.completed`、丢弃 `reasoning.available`)
|
||||||
|
- `_pushEvent` 基于 `userId` 路由到正确的 SSE 连接
|
||||||
|
- 前端已注册了 `agent:chunk`、`agent:thinking`、`agent:done` 的 `addEventListener`(`api.service.ts` L90)
|
||||||
|
|
||||||
|
**因此,后端需要的改动为零。全部工作量集中在前端的「从 Mock 切换到真实 SSE 消费」**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 改造目标
|
||||||
|
|
||||||
|
将 ChatBox 从一个静态 Mock 面板升级为**真实接入后端 Hermes Agent 的上下文对话终端**,同时在 UI 层面达到专业级的对话体验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 上下文路由机制
|
||||||
|
|
||||||
|
### 3.1 chatId 与 contextId 的绑定规则
|
||||||
|
|
||||||
|
ChatBox 的核心是两个 ID:
|
||||||
|
|
||||||
|
| ID | 来源 | 用途 |
|
||||||
|
|----|------|------|
|
||||||
|
| `contextId` | 从当前页面 URL 解析 | 告诉后端"AI 应该读哪些文件"作为 RAG 上下文 |
|
||||||
|
| `chatId` | 前端生成,与 contextId 绑定 | 标识当前对话会话,用于加载历史和持久化 |
|
||||||
|
|
||||||
|
**URL → contextId 的解析规则**(在 `app.ts` 路由监听中执行):
|
||||||
|
|
||||||
|
```
|
||||||
|
/deepview/report/rep_xxx → contextId = "recording:{rep_xxx 对应的 asrId}"
|
||||||
|
或 contextId = "recording:{clientId}/{asrId}" (已归档)
|
||||||
|
/deepview/client/cli_xxx → contextId = "client:cli_xxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
**chatId 的生成规则**:
|
||||||
|
```
|
||||||
|
chatId = "chat_" + contextId.replace(/[:/]/g, "_")
|
||||||
|
```
|
||||||
|
例如:`chat_recording_abc123` 或 `chat_client_cli_001`
|
||||||
|
|
||||||
|
这样保证同一个报告/客户的对话始终复用同一个 chatId,回来能看到之前的追问记录。
|
||||||
|
|
||||||
|
### 3.2 从报告中提取 contextId
|
||||||
|
|
||||||
|
当前 `report-detail.ts` 已经从 API 加载了 `this.report` 对象。该对象中包含 `context_id` 字段(后端 DB `deepview_reports_v2` 表中存储的原始上下文标识)。
|
||||||
|
|
||||||
|
**数据流**:
|
||||||
|
```
|
||||||
|
report-detail.ts 加载 report → 提取 report.context_id →
|
||||||
|
存入共享 Service → app.ts 在 openChatSheet() 时读取
|
||||||
|
```
|
||||||
|
|
||||||
|
需要一个轻量的 `ChatContextService`(单例 Injectable)在 report-detail 和 app.ts 之间桥接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 前端改造清单
|
||||||
|
|
||||||
|
### 4.1 新增 `ChatContextService`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/core/chat-context.service.ts
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ChatContextService {
|
||||||
|
/** 当前页面的上下文 ID */
|
||||||
|
contextId = signal<string>('');
|
||||||
|
/** 当前上下文的人类可读标题 */
|
||||||
|
contextTitle = signal<string>('');
|
||||||
|
/** 上下文类型:recording | client */
|
||||||
|
contextType = signal<'recording' | 'client' | ''>('');
|
||||||
|
|
||||||
|
/** 基于 contextId 派生 chatId */
|
||||||
|
get chatId(): string {
|
||||||
|
const ctx = this.contextId();
|
||||||
|
return ctx ? 'chat_' + ctx.replace(/[:/]/g, '_') : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 修改 `report-detail.ts`
|
||||||
|
|
||||||
|
在 `loadReportFromApi` 成功后:
|
||||||
|
```typescript
|
||||||
|
this.chatCtx.contextId.set(data.context_id || `recording:${reportId}`);
|
||||||
|
this.chatCtx.contextTitle.set(data.clientName || '未归档录音');
|
||||||
|
this.chatCtx.contextType.set('recording');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 修改 `app.ts` 的 Chat 逻辑
|
||||||
|
|
||||||
|
#### 删除所有 Mock 代码
|
||||||
|
删除 `openChatSheet()` 中的硬编码欢迎词和 `sendChatMessage()` 中的 `setTimeout` 假回复。
|
||||||
|
|
||||||
|
#### `openChatSheet()` 改为:
|
||||||
|
1. 从 `ChatContextService` 读取 `chatId` 和 `contextId`。
|
||||||
|
2. 调用 `GET /deepview/chat/history?chatId=xxx` 加载历史消息。
|
||||||
|
3. 如果历史为空,推一条系统欢迎消息(基于 `contextType` 和 `contextTitle` 动态生成)。
|
||||||
|
|
||||||
|
#### `sendChatMessage()` 改为:
|
||||||
|
1. 将用户消息推入 `chatMessages` 数组(即时展示)。
|
||||||
|
2. 推入一条 `{ role: 'agent', content: '', isStreaming: true }` 占位。
|
||||||
|
3. 调用 `POST /deepview/chat` 传递 `{ chatId, text, contextId }`。
|
||||||
|
4. 通过已有的 SSE 监听 (`api.listenToEvents()`) 接收 `agent:chunk` 和 `agent:done` 事件,实时追加到占位消息的 content 中。
|
||||||
|
|
||||||
|
#### 消息接口扩展:
|
||||||
|
```typescript
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'agent';
|
||||||
|
content: string;
|
||||||
|
isStreaming?: boolean; // 正在流式接收中
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 `ApiService` 新增方法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async getChatHistory(chatId: string): Promise<{messages: any[]}> {
|
||||||
|
return this.http.get<any>(`${this.apiUrl}/chat/history`, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
params: { chatId }
|
||||||
|
}).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendChat(chatId: string, text: string, contextId: string): Promise<{received: boolean}> {
|
||||||
|
return this.http.post<any>(`${this.apiUrl}/chat`, {
|
||||||
|
chatId, text, contextId
|
||||||
|
}, { headers: this.getHeaders() }).toPromise();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI 改造规格
|
||||||
|
|
||||||
|
### 5.1 ChatBox Header 动态化
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```html
|
||||||
|
<h4>💬 X光片追踪深打</h4>
|
||||||
|
<p>上下文已在此锁定,免去前提</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**:
|
||||||
|
```html
|
||||||
|
<h4>💬 X光片追踪深打</h4>
|
||||||
|
<p *ngIf="chatContextType === 'recording'">
|
||||||
|
🎙️ 上下文锁定:本次面诊录音报告
|
||||||
|
</p>
|
||||||
|
<p *ngIf="chatContextType === 'client'">
|
||||||
|
👤 上下文锁定:{{ chatContextTitle }} 全景档案
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 消息气泡 + 头像重构
|
||||||
|
|
||||||
|
**目标效果**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ [ AI ] 消息内容巴拉巴拉... │ ← AI: 方形头像,左对齐
|
||||||
|
│ ■ │
|
||||||
|
│ │
|
||||||
|
│ 用户消息巴拉巴拉... [ 👤 ] │ ← 用户: 圆形头像,右对齐
|
||||||
|
│ ● │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTML 结构改造** (`app.html` 中 chat-history 区域):
|
||||||
|
|
||||||
|
```html
|
||||||
|
@for (msg of chatMessages; track $index) {
|
||||||
|
<div class="chat-message" [class.user]="msg.role === 'user'">
|
||||||
|
<!-- AI 头像:方形 -->
|
||||||
|
<div class="msg-avatar ai-avatar" *ngIf="msg.role !== 'user'">
|
||||||
|
<svg>...</svg> <!-- 或用文字 "深" -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg-bubble" [innerHTML]="msg.content"></div>
|
||||||
|
|
||||||
|
<!-- 用户头像:圆形 -->
|
||||||
|
<div class="msg-avatar user-avatar" *ngIf="msg.role === 'user'">
|
||||||
|
{{ auth.user()?.name?.charAt(0) || '我' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSS 规格**:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.msg-avatar {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 13px; font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI: 方形圆角 + 品牌蓝渐变 */
|
||||||
|
.ai-avatar {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户: 圆形 + 淡紫 */
|
||||||
|
.user-avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f3e8ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI 消息行:左对齐 */
|
||||||
|
.chat-message:not(.user) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户消息行:右对齐 */
|
||||||
|
.chat-message.user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 流式打字效果(Streaming Indicator)
|
||||||
|
|
||||||
|
当 AI 消息处于 `isStreaming: true` 状态时,在气泡尾部追加呼吸光标:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.msg-bubble.streaming::after {
|
||||||
|
content: '▋';
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 后端现有能力清点(经 Xinzong 审计交叉验证,无需改动)
|
||||||
|
|
||||||
|
| 端点/事件 | 实现位置 | 状态 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `POST /deepview/chat` | `_handleChat` L482 | ✅ | 接收 `{chatId, text, contextId}`,异步 `create_task(_runChat)` |
|
||||||
|
| `GET /deepview/chat/history` | `_handleChatHistory` | ✅ | 从 SessionDB 返回 `{messages: [{role, content}, ...]}` |
|
||||||
|
| SSE `agent:chunk` | `_makeStreamDeltaCallback` L216 | ✅ | 流式推送 `{chatId, text}` |
|
||||||
|
| SSE `agent:thinking` | `_makeToolProgressCallback` L245 | ✅ | 推送 `{chatId, step, message}`(如"正在读取 profile.md") |
|
||||||
|
| SSE `agent:done` | `_runChat` L627 | ✅ | 推送 `{chatId, fullAnswer}` 标记回答完成 |
|
||||||
|
| SSE `agent:error` | `_runChat` L634 | ✅ | 推送 `{chatId, message}` 错误信息 |
|
||||||
|
|
||||||
|
### 与 Xinzong 的管线同构性
|
||||||
|
|
||||||
|
```
|
||||||
|
Xinzong SSE Pipeline Deepview SSE Pipeline
|
||||||
|
═══════════════════ ═════════════════════
|
||||||
|
stream_delta → chunk ←→ stream_delta → chunk ← 同源
|
||||||
|
tool.started → thinking ←→ tool.started → thinking ← 同源
|
||||||
|
tool.completed → 丢弃 ←→ tool.completed → 丢弃 ← 同源
|
||||||
|
reasoning → 丢弃 ←→ reasoning → 丢弃 ← 同源
|
||||||
|
None → 丢弃 ←→ None → 丢弃 ← 同源
|
||||||
|
return → done ←→ return → done ← 同源
|
||||||
|
exception → error ←→ exception → error ← 同源
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **试错成本归零**:无需猜测后端推送什么事件、携带什么字段——它们与 Xinzong 完全一致。前端只需将 Xinzong 的 `StateService` 订阅模式(一事件一消费者)复制到 Deepview 的 `app.ts` 中即可。
|
||||||
|
|
||||||
|
后端无需任何改动。所有工作在前端 Angular 完成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 修改文件清单
|
||||||
|
|
||||||
|
| 文件 | 动作 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `src/app/core/chat-context.service.ts` | **[NEW]** | 跨组件共享当前上下文 |
|
||||||
|
| `src/app/core/api.service.ts` | **[MODIFY]** | 新增 `getChatHistory` 和 `sendChat` |
|
||||||
|
| `src/app/pages/report-detail/report-detail.ts` | **[MODIFY]** | 注入 ChatContextService,加载报告后设置上下文 |
|
||||||
|
| `src/app/app.ts` | **[MODIFY]** | 重写 chat 逻辑,接入真实 API 和 SSE |
|
||||||
|
| `src/app/app.html` | **[MODIFY]** | 重构消息气泡结构(头像 + 动态 Header) |
|
||||||
|
| `src/app/app.css` | **[MODIFY]** | 新增头像、流式光标等样式 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 验证计划
|
||||||
|
|
||||||
|
### 自动验证
|
||||||
|
1. 打开 `/deepview/report/rep_xxx` 页面,点击 ChatBox,观察 Header 是否显示"🎙️ 上下文锁定:本次面诊录音报告"。
|
||||||
|
2. 发一条消息,确认 `POST /deepview/chat` 被调用且参数中 `contextId` 正确。
|
||||||
|
3. 确认 SSE `agent:chunk` 事件被正确接收并实时渲染到气泡中。
|
||||||
|
|
||||||
|
### 视觉验证
|
||||||
|
1. AI 消息:方形蓝色头像在左,气泡在右。
|
||||||
|
2. 用户消息:圆形紫色头像在右,蓝色气泡在左。
|
||||||
|
3. 流式打字时,气泡尾部有呼吸光标 `▋`。
|
||||||
|
|
||||||
|
> *本规范指导 ChatBox 从 Mock 阶段进化为真实 AI 对话终端的全部前端改造工作。*
|
||||||
@ -346,11 +346,42 @@
|
|||||||
.chat-history {
|
.chat-history {
|
||||||
flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; padding: 8px 0;
|
flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; padding: 8px 0;
|
||||||
}
|
}
|
||||||
.chat-message { display: flex; justify-content: flex-start; }
|
.chat-message { display: flex; justify-content: flex-start; gap: 8px; }
|
||||||
.chat-message.user { justify-content: flex-end; }
|
.chat-message.user { justify-content: flex-end; gap: 8px; }
|
||||||
|
|
||||||
|
.msg-avatar {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 13px; font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI: 方形圆角 + 品牌蓝渐变 */
|
||||||
|
.ai-avatar {
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户: 圆形 + 淡紫 */
|
||||||
|
.user-avatar {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f3e8ff;
|
||||||
|
color: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-message.user .msg-bubble { background: var(--color-primary); color: white; border-bottom-right-radius: 4px; font-weight: 600;}
|
.chat-message.user .msg-bubble { background: var(--color-primary); color: white; border-bottom-right-radius: 4px; font-weight: 600;}
|
||||||
.chat-message:not(.user) .msg-bubble { background: #f1f5f9; color: var(--color-text-primary); border-top-left-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.03); line-height: 1.5; font-size: 13px;}
|
.chat-message:not(.user) .msg-bubble { background: #f1f5f9; color: var(--color-text-primary); border-top-left-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.03); line-height: 1.5; font-size: 13px;}
|
||||||
.msg-bubble { max-width: 85%; padding: 12px 16px; border-radius: 16px; font-size: 14px; }
|
.msg-bubble { max-width: 85%; padding: 12px 16px; border-radius: 16px; font-size: 14px; word-break: break-word;}
|
||||||
|
|
||||||
|
/* 流式打字效果 */
|
||||||
|
.msg-bubble.streaming::after {
|
||||||
|
content: '▋';
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
color: var(--color-primary);
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
.chat-input-area {
|
.chat-input-area {
|
||||||
flex-shrink: 0; display: flex; gap: 8px; align-items: center; margin-top: 16px; background: #f8fafc; padding: 6px; border-radius: 30px; border: 1px solid #e2e8f0;
|
flex-shrink: 0; display: flex; gap: 8px; align-items: center; margin-top: 16px; background: #f8fafc; padding: 6px; border-radius: 30px; border: 1px solid #e2e8f0;
|
||||||
|
|||||||
@ -125,13 +125,26 @@
|
|||||||
<div class="chat-state" *ngIf="showChatSheet" style="display:flex; flex-direction:column; height: 50vh;">
|
<div class="chat-state" *ngIf="showChatSheet" style="display:flex; flex-direction:column; height: 50vh;">
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<h4>💬 X光片追踪深打</h4>
|
<h4>💬 X光片追踪深打</h4>
|
||||||
<p>上下文已在此锁定,免去前提</p>
|
<p *ngIf="chatCtx.contextType() === 'recording'">🎙️ 上下文锁定:本次面诊录音报告</p>
|
||||||
|
<p *ngIf="chatCtx.contextType() === 'client'">👤 上下文锁定:{{ chatCtx.contextTitle() }} 全景档案</p>
|
||||||
|
<p *ngIf="!chatCtx.contextType()">上下文锁定</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-history">
|
<div class="chat-history">
|
||||||
@for (msg of chatMessages; track $index) {
|
@for (msg of chatMessages; track $index) {
|
||||||
<div class="chat-message" [class.user]="msg.role === 'user'">
|
<div class="chat-message" [class.user]="msg.role === 'user'">
|
||||||
<div class="msg-bubble" [innerHTML]="msg.content"></div>
|
|
||||||
|
<!-- AI 头像:方形 -->
|
||||||
|
<div class="msg-avatar ai-avatar" *ngIf="msg.role !== 'user'">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg-bubble" [class.streaming]="msg.isStreaming" [innerHTML]="msg.content"></div>
|
||||||
|
|
||||||
|
<!-- 用户头像:圆形 -->
|
||||||
|
<div class="msg-avatar user-avatar" *ngIf="msg.role === 'user'">
|
||||||
|
{{ auth.user()?.name?.charAt(0) || '我' }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ApiService } from './core/api.service';
|
import { ApiService } from './core/api.service';
|
||||||
import { AuthService } from './core/auth.service';
|
import { AuthService } from './core/auth.service';
|
||||||
import { LoginComponent } from './pages/login/login';
|
import { LoginComponent } from './pages/login/login';
|
||||||
|
import { ChatContextService } from './core/chat-context.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@ -60,6 +62,7 @@ export class App implements OnDestroy, OnInit {
|
|||||||
private sanitizer = inject(DomSanitizer);
|
private sanitizer = inject(DomSanitizer);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
|
public chatCtx = inject(ChatContextService);
|
||||||
|
|
||||||
private sseSubscription: any;
|
private sseSubscription: any;
|
||||||
|
|
||||||
@ -68,7 +71,7 @@ export class App implements OnDestroy, OnInit {
|
|||||||
chatPlaceholder = '向深维追问...';
|
chatPlaceholder = '向深维追问...';
|
||||||
showChatSheet = false;
|
showChatSheet = false;
|
||||||
chatInput = '';
|
chatInput = '';
|
||||||
chatMessages: { role: string, content: string }[] = [];
|
chatMessages: { role: string, content: string, isStreaming?: boolean }[] = [];
|
||||||
|
|
||||||
agents = [
|
agents = [
|
||||||
{ id: 'asr', name: 'Qwen转写', status: 'pending', iconHtml: this.sanitizer.bypassSecurityTrustHtml(`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>`) },
|
{ id: 'asr', name: 'Qwen转写', status: 'pending', iconHtml: this.sanitizer.bypassSecurityTrustHtml(`<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 7 4 4 20 4 20 7"></polyline><line x1="9" y1="20" x2="15" y2="20"></line><line x1="12" y1="4" x2="12" y2="20"></line></svg>`) },
|
||||||
@ -106,6 +109,45 @@ export class App implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.checkAuthAndLoadProfile();
|
this.checkAuthAndLoadProfile();
|
||||||
|
this.setupSSESubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupSSESubscription() {
|
||||||
|
this.sseSubscription = this.api.listenToEvents().subscribe(event => {
|
||||||
|
const { type, payload } = event;
|
||||||
|
if (!payload || !payload.chatId) return;
|
||||||
|
|
||||||
|
// Only handle events for the current active chat context
|
||||||
|
if (this.chatCtx.chatId() !== payload.chatId) return;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'agent:chunk':
|
||||||
|
const currentStreamingMsg = this.chatMessages.find(m => m.isStreaming && m.role === 'agent');
|
||||||
|
if (currentStreamingMsg) {
|
||||||
|
currentStreamingMsg.content += payload.text;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'agent:thinking':
|
||||||
|
// Optionally show thinking state in UI (e.g. pill inside the message)
|
||||||
|
// For now, we can log it or show a temporary notification if needed
|
||||||
|
console.log('Agent Thinking:', payload.step, payload.message);
|
||||||
|
break;
|
||||||
|
case 'agent:done':
|
||||||
|
const activeStreamingMsg = this.chatMessages.find(m => m.isStreaming && m.role === 'agent');
|
||||||
|
if (activeStreamingMsg) {
|
||||||
|
activeStreamingMsg.isStreaming = false;
|
||||||
|
activeStreamingMsg.content = payload.fullAnswer || activeStreamingMsg.content;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'agent:error':
|
||||||
|
const failedMsg = this.chatMessages.find(m => m.isStreaming && m.role === 'agent');
|
||||||
|
if (failedMsg) {
|
||||||
|
failedMsg.isStreaming = false;
|
||||||
|
failedMsg.content = `[系统异常] ${payload.message}`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Profile Methods ---
|
// --- Profile Methods ---
|
||||||
@ -399,22 +441,55 @@ export class App implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Report Chat Mock Logic ---
|
// --- Report Chat Mock Logic ---
|
||||||
openChatSheet() {
|
async openChatSheet() {
|
||||||
this.showChatSheet = true;
|
this.showChatSheet = true;
|
||||||
if (this.chatMessages.length === 0) {
|
|
||||||
this.chatMessages.push({ role: 'agent', content: '医生您好,我是本场面诊的深维特勤 AI。上下文已锁定为李女士(铂金会员/建档3年),您可以随时向我追问她的历史拒因分析、索要跨品类破冰话术或社交网络杠杆策略。' });
|
// Only load history if we haven't loaded it yet for this context
|
||||||
|
if (this.chatMessages.length === 0 && this.chatCtx.chatId()) {
|
||||||
|
try {
|
||||||
|
const res = await this.api.getChatHistory(this.chatCtx.chatId());
|
||||||
|
if (res && res.messages && res.messages.length > 0) {
|
||||||
|
this.chatMessages = res.messages.map((m: any) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
isStreaming: false
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// No history, push initial greeting
|
||||||
|
const title = this.chatCtx.contextTitle();
|
||||||
|
const greeting = this.chatCtx.contextType() === 'client'
|
||||||
|
? `医生您好,上下文已锁定为:${title}(全景档案)。您可以向我追问历史拒因分析或索要跨品类破冰话术。`
|
||||||
|
: `医生您好,我是本场面诊的深维特勤 AI。上下文已锁定。您可以随时向我追问改进方案。`;
|
||||||
|
this.chatMessages.push({ role: 'agent', content: greeting, isStreaming: false });
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to load chat history:', e);
|
||||||
|
this.chatMessages.push({ role: 'agent', content: '医生您好,我是深维特勤 AI。无法获取历史记录,您可以直接向我提问。', isStreaming: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendChatMessage() {
|
async sendChatMessage() {
|
||||||
if (!this.chatInput.trim()) return;
|
if (!this.chatInput.trim() || !this.chatCtx.chatId()) return;
|
||||||
this.chatMessages.push({ role: 'user', content: this.chatInput });
|
|
||||||
|
const text = this.chatInput;
|
||||||
this.chatInput = '';
|
this.chatInput = '';
|
||||||
|
|
||||||
// Mock response
|
// Push user message
|
||||||
setTimeout(() => {
|
this.chatMessages.push({ role: 'user', content: text, isStreaming: false });
|
||||||
this.chatMessages.push({ role: 'agent', content: '基于李女士的全生命周期档案分析:她连续2次拒绝乔雅登都发生在年底(怕恢复期回老家被看出来)。现在是春季最佳窗口期。建议您用闺蜜张女士的成功案例做同侪种草。' });
|
|
||||||
}, 1200);
|
// Push agent placeholder
|
||||||
|
this.chatMessages.push({ role: 'agent', content: '', isStreaming: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.sendChat(this.chatCtx.chatId(), text, this.chatCtx.contextId());
|
||||||
|
} catch(e) {
|
||||||
|
const failMsg = this.chatMessages.find(m => m.isStreaming && m.role === 'agent');
|
||||||
|
if (failMsg) {
|
||||||
|
failMsg.isStreaming = false;
|
||||||
|
failMsg.content = `发送失败: 请重试`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,19 @@ export class ApiService {
|
|||||||
}).toPromise();
|
}).toPromise();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getChatHistory(chatId: string): Promise<any> {
|
||||||
|
return this.http.get(`${this.apiUrl}/chat/history`, {
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
params: { chatId }
|
||||||
|
}).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendChat(chatId: string, text: string, contextId: string): Promise<any> {
|
||||||
|
return this.http.post(`${this.apiUrl}/chat`, {
|
||||||
|
chatId, text, contextId
|
||||||
|
}, { headers: this.getHeaders() }).toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
listenToEvents(): Observable<any> {
|
listenToEvents(): Observable<any> {
|
||||||
const subject = new Subject<any>();
|
const subject = new Subject<any>();
|
||||||
const token = localStorage.getItem('mindpass_token') || localStorage.getItem('token') || 'dev_user_001';
|
const token = localStorage.getItem('mindpass_token') || localStorage.getItem('token') || 'dev_user_001';
|
||||||
|
|||||||
21
src/app/core/chat-context.service.ts
Normal file
21
src/app/core/chat-context.service.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Injectable, signal, computed } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class ChatContextService {
|
||||||
|
/** 当前页面的上下文 ID */
|
||||||
|
contextId = signal<string>('');
|
||||||
|
|
||||||
|
/** 当前上下文的人类可读标题 */
|
||||||
|
contextTitle = signal<string>('');
|
||||||
|
|
||||||
|
/** 上下文类型:recording | client | '' */
|
||||||
|
contextType = signal<'recording' | 'client' | ''>('');
|
||||||
|
|
||||||
|
/** 基于 contextId 派生 chatId */
|
||||||
|
chatId = computed(() => {
|
||||||
|
const ctx = this.contextId();
|
||||||
|
return ctx ? 'chat_' + ctx.replace(/[:\/]/g, '_') : '';
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterLink, ActivatedRoute } from '@angular/router';
|
import { RouterLink, ActivatedRoute } from '@angular/router';
|
||||||
import { MOCK_PATIENTS, Patient } from '../../data/mock-data';
|
import { MOCK_PATIENTS, Patient } from '../../data/mock-data';
|
||||||
|
import { ChatContextService } from '../../core/chat-context.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-client-profile',
|
selector: 'app-client-profile',
|
||||||
@ -13,12 +14,22 @@ import { MOCK_PATIENTS, Patient } from '../../data/mock-data';
|
|||||||
export class ClientProfile implements OnInit {
|
export class ClientProfile implements OnInit {
|
||||||
patient: Patient | undefined;
|
patient: Patient | undefined;
|
||||||
|
|
||||||
constructor(private route: ActivatedRoute) {}
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private chatCtx: ChatContextService
|
||||||
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.route.paramMap.subscribe(params => {
|
this.route.paramMap.subscribe(params => {
|
||||||
const id = params.get('id');
|
const id = params.get('id');
|
||||||
this.patient = MOCK_PATIENTS.find(p => p.id === id) || MOCK_PATIENTS[0];
|
this.patient = MOCK_PATIENTS.find(p => p.id === id) || MOCK_PATIENTS[0];
|
||||||
|
|
||||||
|
// Setup Chat Context
|
||||||
|
if (this.patient) {
|
||||||
|
this.chatCtx.contextId.set(`client:${this.patient.id}`);
|
||||||
|
this.chatCtx.contextTitle.set(this.patient.name);
|
||||||
|
this.chatCtx.contextType.set('client');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { MOCK_REPORTS, Report } from '../../data/mock-data';
|
|||||||
import { MOCK_CLIENTS } from '../../core/client-mock';
|
import { MOCK_CLIENTS } from '../../core/client-mock';
|
||||||
import { ApiService } from '../../core/api.service';
|
import { ApiService } from '../../core/api.service';
|
||||||
import { AuthService } from '../../core/auth.service';
|
import { AuthService } from '../../core/auth.service';
|
||||||
|
import { ChatContextService } from '../../core/chat-context.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-report-detail',
|
selector: 'app-report-detail',
|
||||||
@ -18,6 +19,7 @@ export class ReportDetail implements OnInit {
|
|||||||
report: Report | any;
|
report: Report | any;
|
||||||
private api = inject(ApiService);
|
private api = inject(ApiService);
|
||||||
public auth = inject(AuthService);
|
public auth = inject(AuthService);
|
||||||
|
private chatCtx = inject(ChatContextService);
|
||||||
|
|
||||||
// Archiving State
|
// Archiving State
|
||||||
archiveStatus: 'unarchived' | 'archived' = 'unarchived';
|
archiveStatus: 'unarchived' | 'archived' = 'unarchived';
|
||||||
@ -48,6 +50,12 @@ export class ReportDetail implements OnInit {
|
|||||||
if (data && data.xray) {
|
if (data && data.xray) {
|
||||||
this.report = data;
|
this.report = data;
|
||||||
this.archiveStatus = data.clientId ? 'archived' : 'unarchived';
|
this.archiveStatus = data.clientId ? 'archived' : 'unarchived';
|
||||||
|
|
||||||
|
// Setup Chat Context
|
||||||
|
this.chatCtx.contextId.set(data.context_id || `recording:${reportId}`);
|
||||||
|
this.chatCtx.contextTitle.set(data.clientName || '未归档录音');
|
||||||
|
this.chatCtx.contextType.set('recording');
|
||||||
|
|
||||||
// 同步写 localStorage 作为离线缓存
|
// 同步写 localStorage 作为离线缓存
|
||||||
localStorage.setItem(`deepview_report_${reportId}`, JSON.stringify(data));
|
localStorage.setItem(`deepview_report_${reportId}`, JSON.stringify(data));
|
||||||
return;
|
return;
|
||||||
@ -66,6 +74,11 @@ export class ReportDetail implements OnInit {
|
|||||||
this.report = MOCK_REPORTS.find(r => r.id === reportId) || MOCK_REPORTS[0];
|
this.report = MOCK_REPORTS.find(r => r.id === reportId) || MOCK_REPORTS[0];
|
||||||
this.archiveStatus = this.report.clientId ? 'archived' : 'unarchived';
|
this.archiveStatus = this.report.clientId ? 'archived' : 'unarchived';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Setup Chat Context for cached/mock data
|
||||||
|
this.chatCtx.contextId.set(this.report.context_id || `recording:${reportId}`);
|
||||||
|
this.chatCtx.contextTitle.set(this.report.clientName || '未归档录音');
|
||||||
|
this.chatCtx.contextType.set('recording');
|
||||||
}
|
}
|
||||||
|
|
||||||
filterClients() {
|
filterClients() {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user