feat(chat): 完成分类锚点坞(CategoryDock)与双轨制未读重构 (含方案A轮次归一化与起始位置居中)
This commit is contained in:
@@ -320,3 +320,18 @@ CREATE TABLE IF NOT EXISTS document_audit_logs (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_doc_audit_doc_time ON document_audit_logs(doc_id, created_at DESC);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 11. 会话已读位点双轨表 (Chat Read Positions)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS chat_read_positions (
|
||||
session_id VARCHAR(64) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
last_read_message_id BIGINT NOT NULL DEFAULT 0,
|
||||
dismissed_unread_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (session_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_read_positions_session_user
|
||||
ON chat_read_positions (session_id, user_id);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- ==============================================================================
|
||||
-- 迁移脚本: chat_read_positions 双轨制未读标记与已读位点管理表
|
||||
-- 日期: 2026-09-11
|
||||
-- 描述:
|
||||
-- 支持连续已读基线 (last_read_message_id) 与离散倒序消费补丁集合 (dismissed_unread_ids)
|
||||
-- ==============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_read_positions (
|
||||
session_id VARCHAR(64) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
last_read_message_id BIGINT NOT NULL DEFAULT 0,
|
||||
dismissed_unread_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (session_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_read_positions_session_user
|
||||
ON chat_read_positions (session_id, user_id);
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
# 分类准确性治理、Dock 式锚点坞与未读体系重设计规范
|
||||
|
||||
- **归档路径**: `docs/superpowers/specs/2026-09-10-category-dock-anchors-and-unread-redesign-design.md`
|
||||
- **编制日期**: 2026-09-10
|
||||
- **状态**: 待评审 (Draft for Review)
|
||||
- **性质**: 前序设计 `2026-09-10-chat-history-archive-navigation-and-daily-digest-design.md` 落地后的**实施反馈修正版**(Fix-up Spec)
|
||||
- **关联文档**:
|
||||
- `docs/superpowers/specs/2026-09-10-chat-history-archive-navigation-and-daily-digest-design.md`(P0-P3 已实施:窗口化、日历、搜索、摘要、导出、未读)
|
||||
- `internal/intent/types.go`(意图枚举单一事实源)
|
||||
- `internal/ctxmgr/category.go` / `deploy/postgres/migrations/2026-09-10-chat-messages-day-window.sql`(现存两套分类词表)
|
||||
|
||||
---
|
||||
|
||||
## 一、 问题确认与根因分析 (Confirmed Issues & Root Causes)
|
||||
|
||||
> 以下两个问题经代码实证**全部确认存在**,且根因比表面现象更深。逐条给出证据链。
|
||||
|
||||
### 1.1 问题一 A:点击分类筛选后不发生定位,仍需手动滚动寻找
|
||||
|
||||
**用户现象**:点击顶部分类 chip 后消息流出现"置灰/高亮"视觉差异,但视口不移动,还得自己滚动找目标消息。
|
||||
|
||||
**证据链**:
|
||||
|
||||
| # | 环节 | 代码位置 | 实际行为 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 1 | 点击入口 | `web/src/App.tsx:1944` `onSelectCategory={chatWindow.setSelectedCategory}` | 仅调用 state setter |
|
||||
| 2 | 状态层 | `web/src/hooks/useChatWindow.ts:70` `setSelectedCategory` | 纯 `useState` 更新,**无任何滚动/跳转逻辑** |
|
||||
| 3 | 渲染层 | `web/src/components/chat/MessageStream.tsx:240-242` `isCategoryDimmed={selectedCategory !== 'all' && msg.category !== selectedCategory}` | 仅对非目标类消息加灰度/淡化样式(`index.css` `filter: grayscale(0.5)`) |
|
||||
| 4 | 缺失物 | 前序设计 §7.4 中"侧栏列出该类全部锚点,点击逐个跳" | **未实施**——`navigateToAnchor` 引擎已就绪(`anchorNavigator.ts`,含虚拟滚动句柄快/慢路径),但没有任何分类入口调用它 |
|
||||
|
||||
**结论**:分类筛选被实施成了"纯视觉过滤",与锚点定位协议**没有接线**。用户心智是"点了就要看到",实际是"点了还要自己找"。
|
||||
|
||||
### 1.2 问题一 B:分类不准、不全——"很多 CASUAL_CHAT 的,一部分是日常,一部分是未分类"
|
||||
|
||||
**用户现象**:闲聊类对话中,有的消息归入"日常协同",有的落"未分类"。
|
||||
|
||||
**证据链(四个根因叠加)**:
|
||||
|
||||
| # | 根因 | 证据 | 后果 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| B1 | **助手消息意图断裂**:助手回复落库的 `intent` 用协调层输出 `coordRes.Intent`,而非本轮 L1 识别的用户意图 | `internal/chat/session.go:349` `Intent: coordRes.Intent`;`:358` 落库 `RecordUserMessage(..., "assistant", ..., string(coordRes.Intent), ...)` | 闲聊轮次中:用户消息 `CASUAL_CHAT`→"日常协同";助手回复若 `coordRes.Intent` 为空/被改写→"未分类"。**同一轮对话被劈成两半,与用户观察完全吻合** |
|
||||
| B2 | **意图词表三处漂移 + 幽灵意图**:`intent/types.go` 注册 17 个意图;`DeriveMessageCategory` 仅覆盖 9 个(+`ADVISOR_BRIEF`);SQL 回填是第三套词表(7 个,且优先级顺序与 Go 不一致);协调层还输出 `CONFIRM_ACTION`、`EXECUTE_STAGE_ADVANCE` 等**未注册幽灵意图** | `internal/intent/types.go:10-36` vs `internal/ctxmgr/category.go:46-66` vs `migrations/2026-09-10-chat-messages-day-window.sql:24-33`;`session.go:373-375` 出现幽灵意图 | `QUERY_SIMILAR_CASES`、`QUERY_USER_RIGHTS`、`QUERY_REJECT_REASON`、`TRIGGER_ADHOC_TASK`、`DECLINE_EXPECTATION`、`SECURITY_FORBIDDEN`、`CONFIRM_ACTION`、`EXECUTE_STAGE_ADVANCE` 等全部落 `uncategorized` |
|
||||
| B3 | **本地 fallback / demo 消息无标准意图**:无后端时 `useChatWindow` 用 `initialFallbackMessages` 包装窗口;demo 种子消息的 `intentTag` 是展示性文案(如"💡 掌超项目摸底")而非意图枚举 | `useChatWindow.ts:94-103`;`storage.ts` `DEFAULT_MESSAGES` | 弱网/演示场景整段消息落"未分类" |
|
||||
| B4 | **展示命名含糊**:`casual` 展示名"日常协同"听起来像正经工作协同,实际语义是闲聊寒暄 | `category.go:90` / `CategoryFilterBar.tsx:41` | 用户无法从名字推断筛选结果是什么 |
|
||||
|
||||
**结论**:分类是"打标链路"问题而非 UI 问题——打标源头(意图)断裂、词表失同步、存量与本地数据无标准值。**必须先治数据,再谈导航**(否则 Dock 锚点坞跳到的也是错的地方)。
|
||||
|
||||
### 1.3 问题二:"未读协同消息"语义错误且无法理解、无法消除
|
||||
|
||||
**用户现象**:日历日期格与日历按钮出现橙色"未读"角标,不知道"未读协同消息"指什么、怎么找到它们、怎么消掉。
|
||||
|
||||
**证据链(四个根因叠加,语义完全错误)**:
|
||||
|
||||
| # | 根因 | 证据 | 后果 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| C1 | **`since` 基线缺失**:前端调用未传 `sinceMessageId` → 后端 `sinceID=0` → 会话**全部历史消息**都算未读 | `web/src/App.tsx:770` `useUnreadMarks(activeSessionId)`(第二参缺省);`chat_unread_api.go:29-36` `sinceID` 解析为 0 | 打开老会话,日历上几乎**每天都挂红点**,数字=当天 user 消息数 |
|
||||
| C2 | **统计口径把"自己的消息"算成未读**:只统计 `role=="user"` 且**不比较 `user_id`** | `chat_unread_api.go:60-62` `if msg.Role != "user" { continue }`,全程无 `UserID` 过滤 | 当前"每人独立私聊会话"模型下,session 内所有 user 消息都是**用户自己发的** →"未读协同消息"实际=**你自己发过的消息**,语义彻底颠倒;而真正该提示的智能体新回复(assistant)反而不算未读 |
|
||||
| C3 | **已读位点无持久化、点击不清除**:`clearUnread` 仅改前端 state;日历 `onSelectDate` 只调 `jumpToDate` 未调 `clearUnread`;30s 轮询会把红点重新拉回 | `useUnreadMarks.ts:75-77`;`App.tsx:1956-1959` | 红点**永远消不掉**,用户挫败感叠加 |
|
||||
| C4 | **零解释性 UI**:角标无 tooltip、无说明文案、无引导 | `HistoryCalendar.tsx:261-284`;`CategoryFilterBar.tsx:272-295` | 用户(含产品负责人本人)"一头雾水" |
|
||||
|
||||
**结论**:未读体系当前把"用户自己的历史发言"标记为"未读协同消息",属于**语义级缺陷**,需要按"谁的消息、什么时间之后、我看没看过"三个正交维度重定义,并补齐持久化与解释性。
|
||||
|
||||
---
|
||||
|
||||
## 二、 设计原则 (Design Principles)
|
||||
|
||||
来自产品负责人的目标陈述:"系统的目标是尽可能辅助用户,降低用户的工作复杂度、成本……各个展现方式、使用方法一定要**简单、明确,看一眼或者点一下就懂这是什么,知道该怎么用**"。转译为四条可验收的准则:
|
||||
|
||||
| # | 准则 | 含义 | 违例示例(现状) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| P1 | **点击即达** | 任何筛选/导航入口点击后,视口必须移动到目标内容,禁止"只变样式不动视口" | 分类 chip 只置灰不跳转 |
|
||||
| P2 | **名词自释** | 任何分类名、角标、徽标必须能从字面推断内容,悬停必有 tooltip 说明"是什么 + 点了会怎样" | "未读协同消息"无任何解释 |
|
||||
| P3 | **状态可消除** | 任何提示性状态(红点/角标)必须有明确、即时、持久的消除路径 | 未读红点点不掉、轮询拉回 |
|
||||
| P4 | **数据先行** | 导航类功能的正确性依赖打标数据正确;打标修复优先于 UI 增强 | 分类词表漂移导致导航失真 |
|
||||
|
||||
---
|
||||
|
||||
## 三、 方案 A:分类准确性治理 (Category Accuracy Remediation)
|
||||
|
||||
> **实施顺序上本方案优先于方案 B/C**(P4 准则):分类不准,锚点坞与筛选跳转都会指错地方。
|
||||
|
||||
### 3.1 A1 轮次意图传播(修复 B1:助手消息意图断裂)
|
||||
|
||||
**核心思想**:一轮对话 = 用户消息 + 助手回复。助手消息的"业务分类"天然等于**本轮用户意图的类别**(它是对该意图的应答),而不应由协调层回执的 `coordRes.Intent`(可能是空/改写/幽灵值)决定。
|
||||
|
||||
**改造点**(`internal/chat/session.go`):
|
||||
|
||||
```go
|
||||
// 落库助手消息时,将本轮 L1 识别的用户意图注入 metadata,作为分类的第一依据
|
||||
asstMeta := map[string]any{
|
||||
"turn_intent": string(recRes.TopIntent.Intent), // 本轮用户消息的识别意图 (CASUAL_CHAT 等)
|
||||
"reply_source": coordRes.ReplySource, // pi-agent | template (对齐既有 reply_source 习惯)
|
||||
}
|
||||
if recRes.TopIntent != nil && len(recRes.TopIntent.FilledSlots) > 0 {
|
||||
asstMeta["milestone"] = true // 槽位拍板视为决策里程碑 (既有逻辑迁移)
|
||||
}
|
||||
_ = s.CtxMgr.RecordUserMessage(ctx, s.ID, 0, "assistant",
|
||||
coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), asstMeta)
|
||||
```
|
||||
|
||||
`DeriveMessageCategory` 优先级调整(`internal/ctxmgr/category.go`):`metadata.turn_intent` 的类别 > 卡片/文件/任务元数据 > `intent` 自身 > role 兜底。用户消息照旧走 `intent`;助手消息经 `turn_intent` 传播后,闲聊轮次的用户与助手消息**稳定归入同一类**,"一半日常一半未分类"现象消除。
|
||||
|
||||
### 3.2 A2 意图→分类映射的单一事实源(修复 B2:三处词表漂移 + 幽灵意图)
|
||||
|
||||
1. **映射收敛到一处**:`internal/ctxmgr/category.go` 内建立 `intentCategoryMap`(`map[intent.IntentType]string`)**全量覆盖 `internal/intent/types.go` 的所有注册意图**,并明确新增意图的归属:
|
||||
|
||||
| 意图 | 归类 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| `TRIGGER_ADHOC_TASK` | task | 自由任务升级执行 |
|
||||
| `DECLINE_EXPECTATION` | consult | 暂缓/不采纳,属沟通澄清 |
|
||||
| `QUERY_SIMILAR_CASES` / `QUERY_USER_RIGHTS` / `QUERY_REJECT_REASON` | consult | 查询族 |
|
||||
| `SECURITY_FORBIDDEN` | system | 红线拦截通知 |
|
||||
| `CONFIRM_ACTION` / `EXECUTE_STAGE_ADVANCE`(幽灵意图) | **先在 `types.go` 正式注册**(注册为 `IntentConfirmAction` / `IntentExecuteStageAdvance`)再映射:confirm→task(用户拍板动作)、advance→milestone(阶段推进是里程碑) | 消灭幽灵值 |
|
||||
|
||||
2. **SQL 回填脚本废弃手写词表**:迁移 v2(`2026-09-11-category-remediation.sql`)不再内嵌 CASE 词表,改为**由 Go 程序执行重分类**(见 3.4),SQL 只负责加列/加索引,从机制上杜绝第三套词表。
|
||||
3. **编译期防漂移**:`category_test.go` 增加反射遍历断言——`intent.IntentType` 的全部注册常量都必须出现在 `intentCategoryMap` 或显式豁免清单中,新增意图未映射时**单测失败**,词表从此不可能再漂移。
|
||||
|
||||
### 3.3 A3 展示命名与自解释(修复 B4)
|
||||
|
||||
| 原名 | 新名 | 理由与 tooltip 文案 |
|
||||
| :--- | :--- | :--- |
|
||||
| 日常协同 (casual) | **日常闲聊** | "日常协同"易被误解为正经工作协同;改名后 tooltip:`问候、寒暄与通用帮助,如"你好""明天天气"——点击后跳到最近一条闲聊` |
|
||||
| 任务驱动 (task) | **任务执行** | tooltip:`触发或执行交付物任务的消息(立项摸底、报价、自由任务)` |
|
||||
| 审批审查 (review) | **审批流转** | tooltip:`文档送审、批注、驳回、批准等治理动作与确认卡片` |
|
||||
| 业务咨询 (consult) | 业务咨询(保留) | tooltip:`进度 / 法规 / 案例检索 / 权限等问答与信息补充` |
|
||||
| 里程碑 (milestone) | 关键节点 | tooltip:`阶段推进、拍板决策、槽位确认等关键事件——点击后跳到最近一个节点` |
|
||||
| 文档交付 (file) | 文件收发 | tooltip:`文档与附件的上传、导出、下载` |
|
||||
| 系统通知 (system) | 系统通知(保留) | tooltip:`系统与安全红线提示消息` |
|
||||
| 未分类 (uncategorized) | **其他** | 治理后剩余兜底应极少;tooltip:`暂未识别类别的消息` |
|
||||
|
||||
> **定稿声明**(产品负责人授权编制方定稿命名,原则:语义清晰明确、不与具体对话内容强绑定):上表命名即**最终词表**。单一事实源落位于 `internal/ctxmgr/category.go` 的 `GetCategoryLabel`;前端 `CATEGORY_META` 与导出器 `chatExporter` 的硬编码 label 全部改为**优先消费 `GET /chat/categories` 返回的 `label` 字段**(本地表仅作离线 fallback)——三处命名从此只可能有一处真相,杜绝再度漂移。
|
||||
|
||||
### 3.4 A4 存量重分类(幂等回填 v2 + 管理端点)
|
||||
|
||||
1. **Go 程序化回填**(替代 SQL CASE):新增 `cmd/recategorize/main.go`(或 `scripts/recategorize.go` 复用 ctxmgr),逐批扫描 `chat_messages`:
|
||||
- assistant 消息:`turn_intent` 存在→按映射;否则回溯**同轮用户消息**(按 `created_at` 间隔 < 60s 且 role=user 的前一条)的 intent 推导;再退 `coordRes.Intent` 值;最后普通对话回复→跟随轮次类别;
|
||||
- 幽灵意图值(`CONFIRM_ACTION` 等)按 3.2 新映射归位;
|
||||
- 全程幂等(`WHERE category IS DISTINCT FROM new_category` 才更新),支持重复执行;
|
||||
2. **管理端点**:`POST /api/v1/chat/recategorize`(仅 admin 角色 + dev/test 环境开关),触发异步重分类并返回进度,供词表演进后一键刷新存量;
|
||||
3. **每日摘要联动**:重分类后受影响会话的 `chat_daily_digests` 不重算(摘要不含分类统计,无依赖),但 `GET /chat/categories`、`GET /chat/dates` 的缓存(若有)需失效。
|
||||
|
||||
### 3.5 A5 本地 fallback / demo 消息的分类映射(修复 B3)
|
||||
|
||||
- `storage.ts` `DEFAULT_MESSAGES` 与种子 JSON 的 `intentTag` 展示标签 → 增加离线映射表 `demoIntentToCategory`(如"💡 掌超项目摸底"→task、"问候"→casual),`useChatWindow` 包装 fallback bucket 时补齐 `category` 字段;
|
||||
- 前端 `CategoryFilterBar` 对 `category` 缺失的消息统一归"其他"(uncategorized)并计数,不再静默丢出统计。
|
||||
|
||||
---
|
||||
|
||||
## 四、 方案 B:Dock 式分类锚点坞 (CategoryDock) 与点击即达
|
||||
|
||||
### 4.1 B1 交互规格总览
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┬──────┐
|
||||
│ [全部][任务执行][业务咨询][关键节点][文件收发]… │ 🔔3 │ ← 固定首项: 新消息 (剩余未读 3 · 逐条消费)
|
||||
│ │ ◉ │ ← Dock 项: 关键节点
|
||||
│────────────────────────────────────────────│ ◉ │ ← Dock 项: 任务执行 (放大态)
|
||||
│ │ ◉ │ ← Dock 项: 业务咨询
|
||||
│ 消 息 流 (虚拟滚动) │ ◉ │ ← Dock 项: 文件收发
|
||||
│ │ ◉ │ ← Dock 项: 审批流转
|
||||
│ 9月10日 ──────────────────── 38 条 │ ◉ │ ← Dock 项: 日常闲聊
|
||||
│ ▸ [置灰消息…] [目标消息·脉冲高亮] │ │
|
||||
│ │ │
|
||||
└────────────────────────────────────────────┴──────┘
|
||||
中间聊天栏右缘 · 垂直居中 · 半透明细条
|
||||
```
|
||||
|
||||
- **位置**:消息流容器内右缘、垂直居中(`position:absolute; right:6px; top:50%; transform:translateY(-50%)`),不占用布局空间;
|
||||
- **形态**:竖向排列的圆点式 Dock 项(复用 `CATEGORY_META` 的图标与主题色),每项 = 分类图标 + 当前窗口内该类消息数角标;
|
||||
- **默认态低调**:18px 半透明圆点(opacity 0.45),不与消息内容争夺注意力;鼠标进入 Dock 热区后进入**放大态**;
|
||||
- **内容物**:仅显示**当前窗口内实际存在该类消息**的分类(窗口外分类不显示,避免点了跳不过去的无效锚点);"全部"不进 Dock(点 Dock 就是去某类,语义唯一);
|
||||
- **固定首项"新消息"(🔔)**:Dock 顶部固定一个特殊项(🔔 图标 + 剩余未读数角标),不随窗口内容增减、不参与分类筛选,承担**逐条消费未读**职责——单击跳到剩余未读中时间最近的一条,读完即清除、再点下一条(产品负责人定义的循环消费模式,完整规格见 5.3 C3')。
|
||||
|
||||
### 4.2 B2 macOS Dock 式悬浮放大(magnification)算法
|
||||
|
||||
竖向 Dock 的放大语义:鼠标纵向接近某项时该项放大,**上下邻近项按距离衰减放大并位移让位**(对应 macOS Dock 横向放大时左右图标推开的效果)。
|
||||
|
||||
```typescript
|
||||
// web/src/components/chat/CategoryDock.tsx 核心(伪代码级规格)
|
||||
const BASE = 18, MAX = 34, INFLUENCE = 90; // 基础/最大直径(px), 影响半径(px)
|
||||
|
||||
function magnify(mouseY: number, items: { center: number }[]) {
|
||||
return items.map(({ center }) => {
|
||||
const dist = Math.abs(mouseY - center);
|
||||
if (dist >= INFLUENCE) return { scale: 1, offset: 0 };
|
||||
// 三角衰减 (macOS 风格): 距离越近放大越显著, 边缘平滑归 1
|
||||
const t = 1 - dist / INFLUENCE;
|
||||
const scale = 1 + (MAX / BASE - 1) * t * t;
|
||||
// 让位位移: 邻近项向远离鼠标方向推开 (保持Dock总长度视觉稳定)
|
||||
const push = (MAX - BASE) * 0.5 * t * (mouseY > center ? -1 : 1);
|
||||
return { scale, offset: push };
|
||||
});
|
||||
}
|
||||
// 渲染: style={{ transform: `scale(${scale}) translateY(${offset}px)`,
|
||||
// transformOrigin: 'center' }} + width/height = BASE
|
||||
// 性能: onMouseMove 进 requestAnimationFrame 批处理; scale/translate 均走 GPU 合成层
|
||||
// 退出: onMouseLeave 时全部项经 CSS transition (120ms ease-out) 回落到默认态
|
||||
```
|
||||
|
||||
实现约束:
|
||||
- **纯 React + inline style**,与现有组件零依赖风格一致(不引入动画库);
|
||||
- `transition` 仅在进入/退出放大态时启用(鼠标移动过程中直接跟随,避免"追不上"的粘滞感——macOS 原生行为);
|
||||
- 数量角标在放大态才显示(`scale > 1.6` 时淡入),避免默认态视觉噪音;
|
||||
- **触屏/键盘降级**(P2 准则的可达性延伸):无 hover 设备上 Dock 项固定 24px 可点击尺寸,`Tab` 聚焦等效放大并显示 tooltip;点击行为完全一致。
|
||||
|
||||
### 4.3 B3 点击行为:点击即达(修复问题一 A)
|
||||
|
||||
**统一心智模型:任何分类入口点击后,视口必须移动(P1 准则)。**
|
||||
|
||||
| 入口 | 点击行为 |
|
||||
| :--- | :--- |
|
||||
| 顶部分类 chip | 设定筛选 + **立即跳转到当前窗口内该类最近一条消息**(`navigateToAnchor`,居中 + 脉冲高亮 1.8s);窗口内无该类(统计有但未加载进窗口)→ 走慢路径 `loadDayForAnchor` 拉该类最近一条所在日再定位 |
|
||||
| Dock 分类项(单击) | 同上:跳转该类**最近一条**(不改变当前筛选态——Dock 是"看一眼"工具,chip 才是筛选开关,二者职责分离) |
|
||||
| Dock 分类项(长按/hover 停留 > 600ms) | 展开迷你索引(见 4.4) |
|
||||
| **Dock"新消息"固定项(单击)** | **逐条消费循环**:跳到剩余未读中**时间最近的一条**(`next_message_id` 服务端直出)→ 定位成功即标记该条已读 → 角标 -1 → 再次单击跳剩下未读中时间最近的一条,以此类推(完整规格见 5.3 C3') |
|
||||
| 迷你索引条目 | `navigateToAnchor` 直达该条 |
|
||||
|
||||
**跳转方向约定**:跳"最近一条"(窗口底部方向)——用户找的多是"最近干的事";迷你索引提供反向(更早)遍历能力。
|
||||
|
||||
**与既有设施的复用**(零新增后端):
|
||||
- 定位:`anchorNavigator.navigateToAnchor()`(快路径 → `VirtualScrollHandle.scrollToMessageId` → 慢路径 `loadDayForAnchor`,已全部就绪);
|
||||
- 置灰保留:跳转后非目标类消息保持淡化(既有 `isCategoryDimmed`),目标消息脉冲高亮——**视口移动 + 视觉差异双重确认**,用户瞬间知道"我到了、这就是";
|
||||
- 数据:Dock 项与角标由 `displayDayStack` 内存计算(每类 count + 各消息 serverId 列表),不发请求。
|
||||
|
||||
### 4.4 B4 迷你索引(Dock 项展开的该类消息速览)
|
||||
|
||||
hover 停留 > 600ms 或长按 Dock 项,向左侧展开浮层(`position:absolute; right:40px`,最大高度 60vh 内滚动):
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ 任务执行 · 窗口内 12 条 │
|
||||
│ ────────────────────────────│
|
||||
│ ▸ 09-10 14:32 生成CDO报价表 │ ← 时间 + 首句摘要(≤24字) + 作者
|
||||
│ ▸ 09-09 11:05 立项摸底任务完成 │ 点击即 navigateToAnchor
|
||||
│ ▸ 09-08 16:40 自由任务:竞品对标 │
|
||||
│ … │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
- 条目按时间倒序,每条 = `MM-DD HH:mm` + 内容首句截断 + 类别图标着色;
|
||||
- 点击条目 → 关闭浮层 + `navigateToAnchor`;
|
||||
- 该类消息超过 30 条 → 底部提示"仅显示最近 30 条,可用搜索精确定位"(引流至既有 `ChatSearchModal` 分类过滤);
|
||||
- **这就是前序设计 §7.4 未实施的"该类锚点列表"的最终形态**——挂在 Dock 上而非左侧栏,更贴近消息流上下文。
|
||||
|
||||
### 4.5 B5 Dock 与滚动位置联动(位置感知)
|
||||
|
||||
Dock 同时充当"分类分布滚动条":
|
||||
- 每个 Dock 项的高度按窗口内该类消息占比加权(占比 40% 的分类占 Dock 总高 40%)——Dock 整体即**窗口内容的分类密度图**;
|
||||
- 当前视口中心所处消息的分类,对应 Dock 项点亮(实心 + 满透明度),随滚动实时变化——用户不点击也能"看一眼"知道当前所处位置的内容类别构成;
|
||||
- 需要时可在 Dock 内加一条 2px 当前位置游标线(复用 `useVirtualMessages` 的 `virtualizer.range` 计算视口中心消息),成本极低。
|
||||
|
||||
**可见性规则**:窗口消息 < 10 条(新会话)时 Dock 自动隐藏;消息流 loading 态隐藏;`prefers-reduced-motion` 用户关闭 magnification 动画仅保留放大终态。
|
||||
|
||||
---
|
||||
|
||||
## 五、 方案 C:未读体系重设计 (Unread Semantics Redesign)
|
||||
|
||||
### 5.1 C1 未读语义重定义(修复 C1/C2)
|
||||
|
||||
**一句话定义(展示给用户的语言)**:"我不在线时,这个会话里**别人发的新消息**——包括同事的发言和参谋小队的新回复。"
|
||||
|
||||
**机器定义(按会话模型分轨)**:
|
||||
|
||||
| 会话模型 | 计为未读的消息 | 不计为未读 |
|
||||
| :--- | :--- | :--- |
|
||||
| 私聊会话(当前每人独立 session) | 我上次浏览之后出现的 **assistant(智能体)回复** 与 system 通知 | **我自己发的 user 消息**(现状错误的根源:把 user 全算未读且不比对 user_id) |
|
||||
| 项目协同会话(多用户共享,未来形态) | 我上次浏览之后出现的**其他成员**(`user_id != 我`)消息 + 智能体回复 | 我自己的消息 |
|
||||
|
||||
**计算基线(修复 C1)**:以服务端持久化的**已读位点**(`last_read_message_id`)为唯一基线,替代前端传 `since_message_id`(现状缺省 0 导致全历史皆未读)。前端不再传 since,后端按 `(user_id, session_id)` 查位点;无位点记录视为"全部已读"(首次打开会话不应被历史淹没——历史是用户自己参与的,只有**新**消息才值得提示)。
|
||||
|
||||
### 5.2 C2 已读模型:连续位点 + 单条已读集合(双轨)
|
||||
|
||||
**为什么不能只用位点**:产品负责人定义的未读消费顺序是**从最新往回、逐条乱序消费**(见 5.3)——例如未读 {980, 990, 998, 1000},用户依次跳 1000 → 998 → 990……单靠单调递增位点(`≤ 位点皆已读`)无法表达"998 已读而 999 未读"。因此采用**位点(连续已读区间压缩)+ 单条已读集合(乱序消费记录)**双轨模型:
|
||||
|
||||
```sql
|
||||
-- 迁移: 2026-09-11-chat-read-positions.sql
|
||||
CREATE TABLE IF NOT EXISTS chat_read_positions (
|
||||
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE,
|
||||
last_read_message_id BIGINT NOT NULL DEFAULT 0, -- 连续已读位点: id <= 位点且非本人消息均已读
|
||||
dismissed_unread_ids BIGINT[] NOT NULL DEFAULT '{}', -- 位点之上的单条已读(乱序消费)集合
|
||||
last_read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, session_id)
|
||||
);
|
||||
```
|
||||
|
||||
**未读集合(服务端唯一口径)**:
|
||||
|
||||
```
|
||||
unread(msg) := msg.id > last_read_message_id
|
||||
AND author(msg) != 当前用户 -- 私聊: role ∈ {assistant, system}; 协同: 他人 user + assistant
|
||||
AND msg.id NOT IN dismissed_unread_ids
|
||||
AND msg.deleted_at IS NULL
|
||||
```
|
||||
|
||||
无位点记录(首次打开)视为**全部已读**——历史是用户自己参与的,只有**新**消息才值得提示。
|
||||
|
||||
**API**(`internal/server/chat_unread_api.go` 重写):
|
||||
|
||||
| 端点 | 语义 |
|
||||
| :--- | :--- |
|
||||
| `GET /api/v1/chat/unread?session_id=&user_id=` | 返回 `{ total, next_message_id, items:[{date, count, senders:[{name,count}]}] }`——**`next_message_id` 服务端直出"剩余未读中 id 最大(时间最近)一条"**,前端零计算;`items` 供日历角标与 tooltip(senders 直出"张工 2 条、参谋小队 3 条") |
|
||||
| `POST /api/v1/chat/read` body `{session_id, user_id, read_message_ids?:[], advance_to?:id}` | 双轨写入:单条/批量消费(并入 `dismissed_unread_ids`)或位点推进(`advance_to`);**服务端自动压缩**——`dismissed` 集合与位点连续衔接时推进位点并裁剪数组(如 dismissed={998,1000} 且 999 亦读 → 位点推至 1000、数组清空),防数组无界膨胀;`GREATEST` 只进不退,幂等 |
|
||||
|
||||
**前端上报时机**:
|
||||
1. **视口即读**:虚拟滚动中进入视口的未读消息,节流 5s 批量上报 `read_message_ids`("看到即读过"是统一底层规则);
|
||||
2. **跳转即读**:`navigateToAnchor` / Dock 🔔 消费定位成功后**立即单条上报**(不等节流——产品要求"跳转完成后记得清除该条的未读");
|
||||
3. **发送即读**:用户发消息后位点推进至最新;
|
||||
4. **离开兜底**:`beforeunload` 用 `navigator.sendBeacon` 上报积压批次。
|
||||
|
||||
### 5.3 C3' 未读逐条消费循环(产品负责人定义的核心交互)
|
||||
|
||||
**交互规格**:"跳到最近的一条 → 跳转完成后清除该条的未读 → 再次点击跳到剩下未读中时间最近的一条,以此类推。"
|
||||
|
||||
```
|
||||
状态: 未读 {980, 990, 998, 1000} (我离开后智能体的 4 条新回复)
|
||||
──────────────────────────────────────────────────────────────
|
||||
点击 🔔 → 跳 1000 (时间最近) → 该条即时转已读 → 角标 4→3
|
||||
点击 🔔 → 跳 998 → 已读 → 3→2
|
||||
点击 🔔 → 跳 990 → 已读 → 2→1
|
||||
点击 🔔 → 跳 980 → 已读 → 1→0 → "全部读完 ✓" 反馈后 🔔 淡出
|
||||
──────────────────────────────────────────────────────────────
|
||||
中途滚动浏览到底部 → 视口即读规则 → 剩余未读全部自然消除 → 角标直接归零
|
||||
```
|
||||
|
||||
**规则明细**:
|
||||
|
||||
1. **消费目标**:每次点击跳"剩余未读中时间最近(`id` 最大)的一条",`next_message_id` 由 `GET /chat/unread` 服务端直出;消费后前端请求/换算下一条,连续点击即为**从最新往回**的逐条追溯;
|
||||
2. **即时清除**:定位成功 → 乐观清除(角标 -1、该消息未读描边即时摘除)→ `POST /chat/read` 单条上报(不等 5s 节流);服务端确认后,轮询/增量合并**不再回弹**(修复现状"30s 拉回"问题);
|
||||
3. **收尾反馈**(编制方增补):最后一条消费完毕,🔔 项展示一次性"全部读完 ✓"状态(约 800ms)后淡出隐藏——给用户"没有了"的明确收尾信号(P3 准则),而非无声消失;期间新消息到达则 🔔 复现并轻晃一次;
|
||||
4. **视口即读优先**:滚动浏览中进入视口的未读消息同样即时消除——与逐条消费殊途同归("看到即读过"是统一底层规则,🔔 只是把"下一条"带到眼前的快捷入口);
|
||||
5. **日历红点联动**:日历格角标 = 该日**剩余**未读数,随逐条消费/视口即读**实时递减**;点击日历某日仍跳当日第一条(既有语义),当日未读由视口即读自然消化,而非一次性清空当日;
|
||||
6. **快捷键**(可选增强,编制方增补):`Alt + ↓` 等效点击 🔔 逐条消费,tooltip 中注明——键盘党免鼠标往返;
|
||||
7. **空态**:无未读时 🔔 项隐藏(不占位),日历按钮角标隐藏。
|
||||
|
||||
**轮询降频**:30s 轮询保留仅用于"会话挂在后台时来了新消息"的探测;窗口 `focus` 与 `visibilitychange` 时立即拉取(既有 focus 刷新行为保留)。
|
||||
|
||||
### 5.4 C4 可解释性 UI(修复 C4)
|
||||
|
||||
1. **Dock 🔔 固定项 tooltip**:`还有 4 条新消息 · 点击查看最近一条(读完再点,看下一条)· 快捷键 Alt+↓`;
|
||||
2. **日历角标 tooltip**:`9月8日:张工 2 条新消息、参谋小队 3 条新回复 · 剩余未读随阅读实时递减`(数据来自 C2 的 `senders` 字段);
|
||||
3. **日历按钮角标 tooltip**:`"本会话有 5 条未看的新消息 · 点击展开日历"`;
|
||||
4. **未读消息本体标识**:未读消息气泡左缘显示 2px 橙色描边,**该条被消费(🔔 跳转到达或进入视口)后即时摘除**——用户"看一眼"即知哪几条还没读、哪几条刚读完;
|
||||
5. **空态**:无未读时 🔔 项与日历按钮角标隐藏(正常态)。
|
||||
|
||||
### 5.5 C5 命名修正
|
||||
|
||||
"未读协同消息" → **"新消息"**。理由:用户语言里"未读"绑定"别人发给我、我还没看"的心智;"协同"是系统内部概念(单/多会话模型差异),不该暴露给用户。所有 tooltip 与角标统一使用"新消息 / X 条新回复"。
|
||||
|
||||
---
|
||||
|
||||
## 六、 UI 可解释性规范 (Explainability Matrix)
|
||||
|
||||
> P2 准则的落地清单:本设计触及的**每一个**可视元素都必须满足"hover 有说明、点击有预期"。
|
||||
|
||||
| 元素 | 位置 | hover tooltip | 点击预期 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 分类 chip(含"全部") | 顶栏 | `XX类消息共 N 条(窗口内 M 条)· 点击筛选并跳到最近一条` | 筛选 + 视口跳转 + 目标脉冲高亮 |
|
||||
| Dock 分类项 | 消息流右缘 | `任务执行 · 窗口内 12 条 · 单击跳到最近一条,停驻展开列表` | 单击跳转;停驻展开迷你索引 |
|
||||
| **Dock 🔔 新消息固定项** | Dock 顶部 | `还有 4 条新消息 · 点击查看最近一条(读完再点,看下一条)` | 跳到剩余未读最新一条并即时转已读、角标 -1,连续点击逐条往回消费;读完全部显示"全部读完 ✓"后淡出 |
|
||||
| Dock 迷你索引条目 | 浮层 | 无需(条目本身即说明) | 跳转该条 + 高亮 |
|
||||
| 日历日期格(有消息) | 日历 | `9月8日 · 38 条对话(新消息 5)· 点击跳到当日第一条` | `jumpToDate` + 清红点 |
|
||||
| 日历日期格(无消息) | 日历 | `9月7日 · 无记录` | 禁用 |
|
||||
| 日历/搜索/摘要/导出按钮 | 顶栏右侧 | 既有 title 保留并统一格式 `动作名 · 结果说明` | 对应弹层 |
|
||||
| 未读角标(日历格) | 日历 | `9月8日:张工 2 条新消息、参谋小队 3 条新回复 · 剩余随阅读实时递减` | 同日期格点击;当日未读由视口即读自然消化,角标实时递减 |
|
||||
| 置灰消息 | 消息流 | `非当前筛选类别(任务执行)· 点击"全部"恢复` (气泡整体 hover 时显示) | 无(视觉状态) |
|
||||
|
||||
**统一 tooltip 格式**:`[是什么] · [有多少/状态] · [点击会发生什么]`,三段式,缺一不可。
|
||||
|
||||
---
|
||||
|
||||
## 七、 数据库与 API 变更汇总 (DB & API Changes)
|
||||
|
||||
| 类型 | 变更 | 文件 |
|
||||
| :--- | :--- | :--- |
|
||||
| SQL | 新表 `chat_read_positions`(5.2 双轨:连续位点 + `dismissed_unread_ids` 单条已读集合) | `deploy/postgres/migrations/2026-09-11-chat-read-positions.sql` |
|
||||
| SQL | `chat_messages` 无需新列(`turn_intent` 走 metadata JSONB,免结构变更) | — |
|
||||
| Go | `DeriveMessageCategory` 重构:`intentCategoryMap` 全量映射 + `turn_intent` 优先 + 编译期防漂移单测 | `internal/ctxmgr/category.go` / `category_test.go` |
|
||||
| Go | `session.go` 助手消息落库注入 `turn_intent` / `reply_source` / `milestone` 元数据 | `internal/chat/session.go` |
|
||||
| Go | `intent/types.go` 注册 `CONFIRM_ACTION` / `EXECUTE_STAGE_ADVANCE`(消灭幽灵意图) | `internal/intent/types.go` |
|
||||
| Go | 未读 API 重写(双轨已读 + `next_message_id` 直出 + senders 聚合 + 服务端位点自动压缩)+ `POST /chat/read` 双轨报文 | `internal/server/chat_unread_api.go` |
|
||||
| Go | 管理端点 `POST /api/v1/chat/recategorize` + 回填程序 | `internal/server/` + `cmd/recategorize/` |
|
||||
| Web | `CategoryDock.tsx`(新建:magnification + 迷你索引 + 位置感知 + 🔔 未读消费固定项) | `web/src/components/chat/` |
|
||||
| Web | chip 点击接线 `navigateToAnchor`;`useUnreadMarks` 重写(双轨已读 + `next_message_id` 消费循环 + 乐观清除)+ `useVirtualMessages` 增加视口即读检测 | `App.tsx` / `useUnreadMarks.ts` / `useVirtualMessages.ts` |
|
||||
| Web | 分类命名定稿替换 + 前端 label 改为消费 `GET /chat/categories` 返回值(单一事实源,本地表仅 fallback) | `CategoryFilterBar.tsx` / `chatExporter.ts` / `category.go GetCategoryLabel` |
|
||||
| Web | demo/fallback 消息 `demoIntentToCategory` 映射 | `storage.ts` / `useChatWindow.ts` |
|
||||
|
||||
**兼容性**:`GET /chat/unread` 响应升级为对象包装 `{ total, next_message_id, items }`(`items` 内保留旧字段 `date/count/last_message_id` 并新增 `senders`),前端 `useUnreadMarks` 同 PR 重写适配;`since_message_id` 参数废弃(保留解析但不再生效)。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 八、 测试与验收 (Test & Quality Gate)
|
||||
|
||||
### 8.1 用户故事验收("看一眼/点一下就懂"的可测表述)
|
||||
|
||||
| # | 用户故事 | 验收标准 |
|
||||
| :--- | :--- | :--- |
|
||||
| U1 | 我点"任务执行"chip | 视口 300ms 内移动到窗口内最近一条任务消息并脉冲高亮,非任务消息置灰——**无需手动滚动** |
|
||||
| U2 | 我把鼠标移到右侧 Dock | 邻近图标平滑放大、相邻项让位(macOS 手感);单击任一项即跳转该类最近一条 |
|
||||
| U3 | 我在某 Dock 项上停一下 | 展开该类消息列表(时间+首句),点任一条直达 |
|
||||
| U4 | 我看到日历红点,不知道是什么 | hover 即读出"9月8日:张工 2 条新消息、参谋小队 3 条新回复 · 点击查看"——**不需要问任何人** |
|
||||
| U5 | 我点日历红点日期 | 跳到当日第一条;当日未读气泡带橙色描边一眼可辨,随阅读实时递减;**刷新后计数不回弹** |
|
||||
| U6 | 我翻看历史闲聊 | 用户消息与助手回复同属"日常闲聊",不再一半"未分类" |
|
||||
| U7 | 我反复点 Dock 的 🔔 | 第一次跳到剩余未读中**时间最近一条**并即时转已读(角标 4→3);再点跳次新的,以此类推;全部读完出现"全部读完 ✓"后 🔔 淡出;期间滚动浏览到底亦可自然清零 |
|
||||
|
||||
### 8.2 自动化测试
|
||||
|
||||
| 层 | 文件 | 覆盖 |
|
||||
| :--- | :--- | :--- |
|
||||
| Go 单测 | `internal/ctxmgr/category_test.go`(扩展) | `turn_intent` 优先级、全量意图映射反射断言(防漂移)、幽灵意图归位、assistant 回溯同轮用户消息推导 |
|
||||
| Go 单测 | `internal/chat/session_turn_intent_test.go` | 助手消息落库 metadata 含 `turn_intent`/`reply_source` |
|
||||
| Go 单测 | `internal/ctxmgr/unread_position_test.go` | 双轨未读计算:自己消息不计、assistant 计入、无位点=全已读、`dismissed` 单条消费、位点自动压缩合并、`GREATEST` 只进不退 |
|
||||
| Go API | `internal/server/chat_unread_api_test.go`(重写) | `GET /chat/unread` 双轨计算 + `next_message_id` 直出 + senders 聚合;`POST /chat/read` 单条/批量/位点三型报文幂等;旧参数兼容 |
|
||||
| Go E2E | `scripts/run_category_unread_e2e.py` | 造数(含闲聊轮次、多日历史)→ recategorize → 断言分类分布 → 位点流程全链路 |
|
||||
| Web 单测 | `web/src/components/chat/CategoryDock.test.ts`(vitest) | magnify 数值函数(距离衰减/让位位移边界)、迷你索引渲染、点击回调、**🔔 消费循环(角标递减/收尾反馈)** |
|
||||
| Web 单测 | `web/src/hooks/useUnreadMarks.test.ts`(重写) | **消费循环(next → 跳转 → 即时已读 → 下一条)**、乐观清除、视口即读节流上报、轮询合并不回红 |
|
||||
| Web 单测 | `useChatWindow.test.ts`(扩展) | chip 选中后触发跳转动作(mock navigateToAnchor) |
|
||||
|
||||
### 8.3 性能与手感预算
|
||||
|
||||
| 指标 | 预算 |
|
||||
| :--- | :--- |
|
||||
| chip 点击 → 目标消息居中高亮 | P95 ≤ 300ms(窗口内)/ ≤ 800ms(慢路径) |
|
||||
| Dock magnification 帧率 | ≥ 55fps(rAF + transform 合成层,16 项以内) |
|
||||
| 红点/角标变化 → 可见 | 立即(乐观 UI,0 等待);🔔 连续消费:下一条定位 P95 ≤ 300ms(窗口内) |
|
||||
| 位点上报 | 节流 5s 批量,离页面积压 ≤ 1 次请求 |
|
||||
|
||||
---
|
||||
|
||||
## 九、 实施顺序与风险 (Implementation Order & Risks)
|
||||
|
||||
### 9.1 实施顺序(依赖驱动,小步渐进提交)
|
||||
|
||||
1. **Step 1(治数据,`feat(db)`/`feat(agent)`)**:`turn_intent` 注入 + `intentCategoryMap` 全量映射 + 幽灵意图注册 + 防漂移单测 → **存量 recategorize**;
|
||||
2. **Step 2(治未读语义,`feat(db)`/`feat(api)`)**:`chat_read_positions` 表 + 未读 API 重写 + `POST /chat/read`;
|
||||
3. **Step 3(治文案,`feat(web)`)**:分类改名 + tooltip 矩阵全量替换(第六章);
|
||||
4. **Step 4(点击即达,`feat(web)`)**:chip 接线 `navigateToAnchor`(含慢路径)——**此步完成后问题一 A 即消除**;
|
||||
5. **Step 5(未读 UX,`feat(web)`)**:`useUnreadMarks` 重写(双轨已读 + 消费循环)+ Dock 🔔 固定项(跳转 → 即时已读 → 角标递减 → 收尾反馈)+ `useVirtualMessages` 视口即读检测 + 未读描边与日历角标实时递减;
|
||||
6. **Step 6(Dock,`feat(web)`)**:`CategoryDock`(magnification → 点击 → 迷你索引 → 位置感知,可分 2-3 个小提交渐进)。
|
||||
|
||||
提交规范沿用仓库约定:`feat(db)` / `feat(agent)` / `feat(api)` / `feat(web)` / `test(...)` / `docs(...)`。
|
||||
|
||||
### 9.2 风险与开放问题
|
||||
|
||||
| # | 风险 | 应对 |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | recategorize 回溯"同轮用户消息"的 60s 间隔启发式在快速多轮场景可能配错对 | 仅作为 assistant 无 `turn_intent` 时的**存量兜底**;新数据一律走 `turn_intent`(源头准确);回填后抽样人工校验 20 条 |
|
||||
| 2 | 未读语义切换后,用户已有的"红点很多"的第一印象 | 语义修正 + 无位点=全已读,上线后红点自然清零;无需数据迁移(旧 `since` 行为直接废弃) |
|
||||
| 3 | Dock magnification 在低端设备的性能 | rAF + GPU transform + 项数上限(窗口内分类 ≤ 8);`prefers-reduced-motion` 关动画 |
|
||||
| 4 | 多用户共享会话(未来)下的 senders 聚合需 join users 表 | API 已按 `(user_id != me)` 设计,私聊模型先行,共享会话开放时零改动启用 |
|
||||
| 5 | chip 跳转"最近一条"方向是否反直觉(部分用户想找最早) | 迷你索引与搜索提供反向遍历;后续可加"跳到最早"次级入口(开放,观察使用数据) |
|
||||
| 6 | "新消息"命名与既有 E2E 脚本断言(`unread` 字样)冲突 | 脚本同步更新,属同 PR 内变更 |
|
||||
| 7 | 消费顺序"从最新往回"为产品定义;若未来有用户更想"从最早补课" | 迷你索引/日历已提供任意点跳;可加消费顺序偏好(`unread_order = newest \| oldest`,默认 newest)——开放,待观察使用数据再定 |
|
||||
|
||||
---
|
||||
|
||||
## 附:本设计对前序设计 (§7.4 / §P3) 的修正声明
|
||||
|
||||
| 前序设计条目 | 修正 |
|
||||
| :--- | :--- |
|
||||
| §7.4 `CategoryFilterBar`"窗口内过滤高亮 + 仅看此分类" | 修正为:**点击即达**(跳转最近一条)+ Dock 承载锚点列表(迷你索引),置灰仅作跳转后的上下文弱化 |
|
||||
| §P3 未读提醒(当时未细化,实施时自行发挥导致语义错误) | 本方案第五章整体取代:双轨已读模型(位点 + 单条已读集合)、语义分轨、**逐条消费循环(5.3 C3',产品负责人定义的交互)**、可解释、可消除 |
|
||||
| §4.2 分类词表(intent 判定来源) | 本方案 3.2 全量映射 + 防漂移机制取代手写三处词表 |
|
||||
| casual 展示名"日常协同" | 改为"日常闲聊"(3.3) |
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/intent"
|
||||
)
|
||||
|
||||
// TestAllIntentsMappedToCategory 确保 intent/types.go 定义的全部意图常量都在 ctxmgr.IntentCategoryMap 中有确切分类映射
|
||||
// 杜绝意图增加/重命名导致的分类漂移与野指针
|
||||
func TestAllIntentsMappedToCategory(t *testing.T) {
|
||||
allIntents := []intent.IntentType{
|
||||
intent.IntentQueryProgress,
|
||||
intent.IntentSupplementInfo,
|
||||
intent.IntentAdvanceStage,
|
||||
intent.IntentTriggerTask,
|
||||
intent.IntentTriggerAdhocTask,
|
||||
intent.IntentCasualChat,
|
||||
intent.IntentDeclineExpectation,
|
||||
intent.IntentDocGovClaim,
|
||||
intent.IntentDocGovSubmitReview,
|
||||
intent.IntentDocGovReviewApprove,
|
||||
intent.IntentDocGovReviewReject,
|
||||
intent.IntentDocGovFinalApprove,
|
||||
intent.IntentDocGovFinalReject,
|
||||
intent.IntentDocGovProposalDecide,
|
||||
intent.IntentQueryRegulations,
|
||||
intent.IntentQuerySimilarCases,
|
||||
intent.IntentQueryUserRights,
|
||||
intent.IntentQueryRejectReason,
|
||||
intent.IntentConfirmAction,
|
||||
intent.IntentExecuteStageAdvance,
|
||||
intent.IntentSecurityForbidden,
|
||||
}
|
||||
|
||||
for _, it := range allIntents {
|
||||
cat, ok := ctxmgr.IntentCategoryMap[string(it)]
|
||||
if !ok {
|
||||
t.Errorf("Intent %q is missing from ctxmgr.IntentCategoryMap", it)
|
||||
continue
|
||||
}
|
||||
if cat == "" || cat == ctxmgr.CategoryUncategorized {
|
||||
t.Errorf("Intent %q maps to empty or uncategorized category %q", it, cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,23 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
s.Messages = append(s.Messages, asstMsg)
|
||||
|
||||
if s.CtxMgr != nil {
|
||||
_ = s.CtxMgr.RecordUserMessage(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), nil)
|
||||
asstMeta := map[string]any{
|
||||
"reply_source": coordRes.ReplySource,
|
||||
}
|
||||
if recRes.TopIntent != nil {
|
||||
asstMeta["turn_intent"] = string(recRes.TopIntent.Intent)
|
||||
}
|
||||
isMilestone := false
|
||||
if recRes.TopIntent != nil && len(recRes.TopIntent.FilledSlots) > 0 {
|
||||
isMilestone = true
|
||||
asstMeta["milestone"] = true
|
||||
}
|
||||
if coordRes.Intent == intent.IntentConfirmAction || coordRes.Intent == intent.IntentExecuteStageAdvance || coordRes.Intent == intent.IntentTriggerAdhocTask {
|
||||
isMilestone = true
|
||||
asstMeta["milestone"] = true
|
||||
}
|
||||
|
||||
_ = s.CtxMgr.RecordUserMessage(ctx, s.ID, 0, "assistant", coordRes.NaturalReply, string(coordRes.Intent), string(recRes.Branch), asstMeta)
|
||||
|
||||
// 异步记录问答切片到向量记忆库 (P2 检索增强,不阻塞流式或同步答复)
|
||||
sessID := s.ID
|
||||
@@ -366,13 +382,6 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
ans := coordRes.NaturalReply
|
||||
iName := string(coordRes.Intent)
|
||||
bName := string(recRes.Branch)
|
||||
isMilestone := false
|
||||
if recRes.TopIntent != nil && len(recRes.TopIntent.FilledSlots) > 0 {
|
||||
isMilestone = true
|
||||
}
|
||||
if iName == "CONFIRM_ACTION" || iName == "EXECUTE_STAGE_ADVANCE" || iName == "TRIGGER_ADHOC_TASK" {
|
||||
isMilestone = true
|
||||
}
|
||||
go func() {
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
+53
-26
@@ -1,6 +1,8 @@
|
||||
package ctxmgr
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Category 常量定义
|
||||
const (
|
||||
@@ -14,8 +16,33 @@ const (
|
||||
CategoryUncategorized = "uncategorized"
|
||||
)
|
||||
|
||||
// IntentCategoryMap 意图到分类的标准映射表 (Single Source of Truth)
|
||||
var IntentCategoryMap = map[string]string{
|
||||
"CASUAL_CHAT": CategoryCasual,
|
||||
"TRIGGER_TASK": CategoryTask,
|
||||
"ADVANCE_STAGE": CategoryTask,
|
||||
"TRIGGER_ADHOC_TASK": CategoryTask,
|
||||
"CONFIRM_ACTION": CategoryTask,
|
||||
"EXECUTE_STAGE_ADVANCE": CategoryTask,
|
||||
"DOCGOV_CLAIM": CategoryReview,
|
||||
"DOCGOV_SUBMIT_REVIEW": CategoryReview,
|
||||
"DOCGOV_REVIEW_APPROVE": CategoryReview,
|
||||
"DOCGOV_REVIEW_REJECT": CategoryReview,
|
||||
"DOCGOV_FINAL_APPROVE": CategoryReview,
|
||||
"DOCGOV_FINAL_REJECT": CategoryReview,
|
||||
"DOCGOV_PROPOSAL_DECIDE": CategoryReview,
|
||||
"QUERY_PROGRESS": CategoryConsult,
|
||||
"SUPPLEMENT_INFO": CategoryConsult,
|
||||
"QUERY_REGULATIONS": CategoryConsult,
|
||||
"QUERY_SIMILAR_CASES": CategoryConsult,
|
||||
"QUERY_USER_RIGHTS": CategoryConsult,
|
||||
"QUERY_REJECT_REASON": CategoryConsult,
|
||||
"DECLINE_EXPECTATION": CategoryConsult,
|
||||
"SECURITY_FORBIDDEN": CategorySystem,
|
||||
}
|
||||
|
||||
// DeriveMessageCategory 根据消息元信息与意图推导分类 (按优先级短路)
|
||||
func DeriveMessageCategory(role, intent, branch string, meta map[string]any) string {
|
||||
func DeriveMessageCategory(role, intentStr, branch string, meta map[string]any) string {
|
||||
if meta != nil {
|
||||
// 1. milestone: 里程碑节点
|
||||
if mVal, ok := meta["milestone"]; ok {
|
||||
@@ -41,31 +68,31 @@ func DeriveMessageCategory(role, intent, branch string, meta map[string]any) str
|
||||
if _, ok := meta["task_request"]; ok {
|
||||
return CategoryTask
|
||||
}
|
||||
|
||||
// 若外部传入 intent 为空或 uncategorized,优先从 meta["turn_intent"] 继承本轮主意图
|
||||
if (intentStr == "" || intentStr == "uncategorized") && meta["turn_intent"] != nil {
|
||||
if ti, ok := meta["turn_intent"].(string); ok && ti != "" {
|
||||
intentStr = ti
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. review: 文档治理/审查意图
|
||||
if strings.HasPrefix(intent, "DOCGOV") {
|
||||
// 5. 根据标准映射表匹配意图
|
||||
if cat, ok := IntentCategoryMap[intentStr]; ok {
|
||||
return cat
|
||||
}
|
||||
|
||||
// 6. 兜底匹配:DOCGOV 系列统归 review
|
||||
if strings.HasPrefix(intentStr, "DOCGOV") {
|
||||
return CategoryReview
|
||||
}
|
||||
|
||||
// 6. task: 阶段推进与任务触发意图
|
||||
switch intent {
|
||||
case "TRIGGER_TASK", "ADVANCE_STAGE":
|
||||
return CategoryTask
|
||||
}
|
||||
|
||||
// 7. consult: 咨询与探询
|
||||
switch intent {
|
||||
case "QUERY_PROGRESS", "QUERY_REGULATIONS", "SUPPLEMENT_INFO", "AMBIGUOUS_FALLBACK", "ADVISOR_BRIEF":
|
||||
// 7. 兜底匹配:模糊引导与参谋摘要归 consult
|
||||
if branch == "AMBIGUOUS_FALLBACK" || intentStr == "ADVISOR_BRIEF" {
|
||||
return CategoryConsult
|
||||
}
|
||||
|
||||
// 8. casual: 闲聊意图
|
||||
if intent == "CASUAL_CHAT" {
|
||||
return CategoryCasual
|
||||
}
|
||||
|
||||
// 9. system: 系统角色通知
|
||||
// 8. system 角色通知统归 system
|
||||
if role == "system" {
|
||||
return CategorySystem
|
||||
}
|
||||
@@ -73,24 +100,24 @@ func DeriveMessageCategory(role, intent, branch string, meta map[string]any) str
|
||||
return CategoryUncategorized
|
||||
}
|
||||
|
||||
// GetCategoryLabel 返回分类的标准展示中文标签
|
||||
// GetCategoryLabel 返回分类的标准展示中文标签 (单一事实源定稿词表)
|
||||
func GetCategoryLabel(cat string) string {
|
||||
switch cat {
|
||||
case CategoryMilestone:
|
||||
return "里程碑"
|
||||
return "关键节点"
|
||||
case CategoryReview:
|
||||
return "审批审查"
|
||||
return "审批流转"
|
||||
case CategoryTask:
|
||||
return "任务驱动"
|
||||
return "任务执行"
|
||||
case CategoryFile:
|
||||
return "文档交付"
|
||||
return "文件收发"
|
||||
case CategoryConsult:
|
||||
return "业务咨询"
|
||||
case CategoryCasual:
|
||||
return "日常协同"
|
||||
return "日常闲聊"
|
||||
case CategorySystem:
|
||||
return "系统通知"
|
||||
default:
|
||||
return "未分类"
|
||||
return "其他"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package ctxmgr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDeriveMessageCategory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
role string
|
||||
intentStr string
|
||||
branch string
|
||||
meta map[string]any
|
||||
wantCat string
|
||||
}{
|
||||
{
|
||||
name: "Milestone in metadata has highest priority",
|
||||
role: "assistant",
|
||||
meta: map[string]any{"milestone": true},
|
||||
wantCat: CategoryMilestone,
|
||||
},
|
||||
{
|
||||
name: "File upload in metadata",
|
||||
role: "user",
|
||||
meta: map[string]any{"file_op": "upload"},
|
||||
wantCat: CategoryFile,
|
||||
},
|
||||
{
|
||||
name: "Interactive card in metadata",
|
||||
role: "assistant",
|
||||
meta: map[string]any{"interactive_card": map[string]any{"type": "confirm"}},
|
||||
wantCat: CategoryReview,
|
||||
},
|
||||
{
|
||||
name: "Task request in metadata",
|
||||
role: "user",
|
||||
meta: map[string]any{"task_request": true},
|
||||
wantCat: CategoryTask,
|
||||
},
|
||||
{
|
||||
name: "Assistant message inherits turn_intent from metadata",
|
||||
role: "assistant",
|
||||
intentStr: "",
|
||||
meta: map[string]any{"turn_intent": "CASUAL_CHAT"},
|
||||
wantCat: CategoryCasual,
|
||||
},
|
||||
{
|
||||
name: "Assistant message inherits uncategorized turn_intent fallback",
|
||||
role: "assistant",
|
||||
intentStr: "uncategorized",
|
||||
meta: map[string]any{"turn_intent": "DOCGOV_SUBMIT_REVIEW"},
|
||||
wantCat: CategoryReview,
|
||||
},
|
||||
{
|
||||
name: "Direct intent mapping casual",
|
||||
role: "user",
|
||||
intentStr: "CASUAL_CHAT",
|
||||
wantCat: CategoryCasual,
|
||||
},
|
||||
{
|
||||
name: "Direct intent mapping task",
|
||||
role: "user",
|
||||
intentStr: "TRIGGER_TASK",
|
||||
wantCat: CategoryTask,
|
||||
},
|
||||
{
|
||||
name: "Direct intent mapping review",
|
||||
role: "user",
|
||||
intentStr: "DOCGOV_SUBMIT_REVIEW",
|
||||
wantCat: CategoryReview,
|
||||
},
|
||||
{
|
||||
name: "Direct intent mapping consult",
|
||||
role: "user",
|
||||
intentStr: "QUERY_PROGRESS",
|
||||
wantCat: CategoryConsult,
|
||||
},
|
||||
{
|
||||
name: "Direct intent mapping security system",
|
||||
role: "user",
|
||||
intentStr: "SECURITY_FORBIDDEN",
|
||||
wantCat: CategorySystem,
|
||||
},
|
||||
{
|
||||
name: "System role fallback",
|
||||
role: "system",
|
||||
wantCat: CategorySystem,
|
||||
},
|
||||
{
|
||||
name: "Ambiguous fallback branch",
|
||||
role: "assistant",
|
||||
branch: "AMBIGUOUS_FALLBACK",
|
||||
wantCat: CategoryConsult,
|
||||
},
|
||||
{
|
||||
name: "Uncategorized fallback",
|
||||
role: "user",
|
||||
wantCat: CategoryUncategorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := DeriveMessageCategory(tt.role, tt.intentStr, tt.branch, tt.meta)
|
||||
if got != tt.wantCat {
|
||||
t.Errorf("DeriveMessageCategory() = %v, want %v", got, tt.wantCat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCategoryLabelFinalized(t *testing.T) {
|
||||
expected := map[string]string{
|
||||
CategoryMilestone: "关键节点",
|
||||
CategoryReview: "审批流转",
|
||||
CategoryTask: "任务执行",
|
||||
CategoryFile: "文件收发",
|
||||
CategoryConsult: "业务咨询",
|
||||
CategoryCasual: "日常闲聊",
|
||||
CategorySystem: "系统通知",
|
||||
CategoryUncategorized: "其他",
|
||||
}
|
||||
|
||||
for cat, wantLabel := range expected {
|
||||
if got := GetCategoryLabel(cat); got != wantLabel {
|
||||
t.Errorf("GetCategoryLabel(%q) = %q, want %q", cat, got, wantLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package ctxmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChatReadPosition 双轨制未读标记位点
|
||||
type ChatReadPosition struct {
|
||||
SessionID string `json:"session_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
LastReadMessageID int64 `json:"last_read_message_id"` // 连续已读基线
|
||||
DismissedUnreadIDs []int64 `json:"dismissed_unread_ids"` // 离散跳读/倒序消费的消息 ID
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UnreadDayItem 某日的未读消息聚合项
|
||||
type UnreadDayItem struct {
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
LastMessageID int64 `json:"last_message_id"`
|
||||
Senders []string `json:"senders,omitempty"`
|
||||
}
|
||||
|
||||
// ChatUnreadSummary 未读消息汇总结构体
|
||||
type ChatUnreadSummary struct {
|
||||
Total int `json:"total"`
|
||||
NextMessageID int64 `json:"next_message_id"`
|
||||
UnreadMessageIDs []int64 `json:"unread_message_ids"`
|
||||
Items []UnreadDayItem `json:"items"`
|
||||
}
|
||||
|
||||
// MarkChatReadRequest 标记已读请求
|
||||
type MarkChatReadRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ReadMessageIDs []int64 `json:"read_message_ids,omitempty"`
|
||||
AdvanceTo int64 `json:"advance_to,omitempty"`
|
||||
}
|
||||
|
||||
// GetChatReadPosition 获取会话与用户的已读位点
|
||||
func (m *ContextManager) GetChatReadPosition(ctx context.Context, sessionID string, userID int64) (*ChatReadPosition, error) {
|
||||
if m.pool != nil {
|
||||
row := m.pool.QueryRow(ctx, `
|
||||
SELECT session_id, user_id, last_read_message_id, dismissed_unread_ids, updated_at
|
||||
FROM chat_read_positions
|
||||
WHERE session_id = $1 AND user_id = $2
|
||||
`, sessionID, userID)
|
||||
|
||||
var pos ChatReadPosition
|
||||
if err := row.Scan(&pos.SessionID, &pos.UserID, &pos.LastReadMessageID, &pos.DismissedUnreadIDs, &pos.UpdatedAt); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &pos, nil
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
key := sessionID + ":" + strconv.FormatInt(userID, 10)
|
||||
if pos, ok := m.readPositions[key]; ok {
|
||||
cp := *pos
|
||||
cp.DismissedUnreadIDs = append([]int64(nil), pos.DismissedUnreadIDs...)
|
||||
return &cp, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetChatUnreadSummary 计算当前用户在指定会话的未读汇总
|
||||
func (m *ContextManager) GetChatUnreadSummary(ctx context.Context, sessionID string, userID int64, sinceMessageID int64) (*ChatUnreadSummary, error) {
|
||||
pos, err := m.GetChatReadPosition(ctx, sessionID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get read position failed: %w", err)
|
||||
}
|
||||
|
||||
// 取出该会话所有有效消息 (ID 正序)
|
||||
msgs, err := m.GetChatHistory(ctx, sessionID, 5000, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get chat history failed: %w", err)
|
||||
}
|
||||
|
||||
// 若尚未有位点记录:
|
||||
// 若外部传入了 sinceMessageID > 0,以该 sinceMessageID 作为初始基线;
|
||||
// 否则作为初次打开会话,默认视作全部已读(基线初始化至最新消息 ID)
|
||||
if pos == nil {
|
||||
if sinceMessageID > 0 {
|
||||
pos = &ChatReadPosition{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
LastReadMessageID: sinceMessageID,
|
||||
DismissedUnreadIDs: []int64{},
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
_, _ = m.MarkChatRead(ctx, MarkChatReadRequest{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
AdvanceTo: sinceMessageID,
|
||||
})
|
||||
} else {
|
||||
var maxID int64 = 0
|
||||
for _, msg := range msgs {
|
||||
if msg.ID > maxID {
|
||||
maxID = msg.ID
|
||||
}
|
||||
}
|
||||
if maxID > 0 {
|
||||
_, _ = m.MarkChatRead(ctx, MarkChatReadRequest{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
AdvanceTo: maxID,
|
||||
})
|
||||
}
|
||||
return &ChatUnreadSummary{
|
||||
Total: 0,
|
||||
NextMessageID: 0,
|
||||
UnreadMessageIDs: []int64{},
|
||||
Items: []UnreadDayItem{},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
effectiveBaseline := pos.LastReadMessageID
|
||||
if sinceMessageID > effectiveBaseline {
|
||||
effectiveBaseline = sinceMessageID
|
||||
}
|
||||
|
||||
dismissedMap := make(map[int64]bool, len(pos.DismissedUnreadIDs))
|
||||
for _, id := range pos.DismissedUnreadIDs {
|
||||
dismissedMap[id] = true
|
||||
}
|
||||
|
||||
type dayAgg struct {
|
||||
date string
|
||||
count int
|
||||
lastMessageID int64
|
||||
senderSet map[string]bool
|
||||
}
|
||||
dayMap := make(map[string]*dayAgg)
|
||||
var dayOrder []string
|
||||
var unreadIDs []int64
|
||||
|
||||
for _, msg := range msgs {
|
||||
if msg.DeletedAt != nil {
|
||||
continue
|
||||
}
|
||||
if msg.ID <= effectiveBaseline {
|
||||
continue
|
||||
}
|
||||
// 自己发送的 user 消息不计入未读
|
||||
if msg.UserID == userID && msg.Role == "user" {
|
||||
continue
|
||||
}
|
||||
// 已离散消费的消息不计入未读
|
||||
if dismissedMap[msg.ID] {
|
||||
continue
|
||||
}
|
||||
|
||||
unreadIDs = append(unreadIDs, msg.ID)
|
||||
|
||||
dayKey := msg.DayKey
|
||||
if dayKey == "" && !msg.CreatedAt.IsZero() {
|
||||
dayKey = msg.CreatedAt.Format("2006-01-02")
|
||||
}
|
||||
if dayKey == "" {
|
||||
dayKey = "unknown"
|
||||
}
|
||||
|
||||
agg, ok := dayMap[dayKey]
|
||||
if !ok {
|
||||
agg = &dayAgg{
|
||||
date: dayKey,
|
||||
senderSet: make(map[string]bool),
|
||||
}
|
||||
dayMap[dayKey] = agg
|
||||
dayOrder = append(dayOrder, dayKey)
|
||||
}
|
||||
agg.count++
|
||||
if msg.ID > agg.lastMessageID {
|
||||
agg.lastMessageID = msg.ID
|
||||
}
|
||||
if msg.Role != "" {
|
||||
agg.senderSet[msg.Role] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 倒序排序未读 ID,最新未读排在前面
|
||||
sort.Slice(unreadIDs, func(i, j int) bool {
|
||||
return unreadIDs[i] > unreadIDs[j]
|
||||
})
|
||||
|
||||
var nextID int64 = 0
|
||||
if len(unreadIDs) > 0 {
|
||||
nextID = unreadIDs[0] // 离最新位置最近的一条未读
|
||||
}
|
||||
|
||||
items := make([]UnreadDayItem, 0, len(dayOrder))
|
||||
for _, dk := range dayOrder {
|
||||
agg := dayMap[dk]
|
||||
var senders []string
|
||||
for s := range agg.senderSet {
|
||||
senders = append(senders, s)
|
||||
}
|
||||
sort.Strings(senders)
|
||||
items = append(items, UnreadDayItem{
|
||||
Date: agg.date,
|
||||
Count: agg.count,
|
||||
LastMessageID: agg.lastMessageID,
|
||||
Senders: senders,
|
||||
})
|
||||
}
|
||||
|
||||
return &ChatUnreadSummary{
|
||||
Total: len(unreadIDs),
|
||||
NextMessageID: nextID,
|
||||
UnreadMessageIDs: unreadIDs,
|
||||
Items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarkChatRead 推进已读位点或添加离散消费消息 ID,并执行自动压缩
|
||||
func (m *ContextManager) MarkChatRead(ctx context.Context, req MarkChatReadRequest) (*ChatReadPosition, error) {
|
||||
if req.SessionID == "" {
|
||||
return nil, fmt.Errorf("session_id required")
|
||||
}
|
||||
|
||||
if m.pool != nil {
|
||||
return m.markChatReadPG(ctx, req)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
key := req.SessionID + ":" + strconv.FormatInt(req.UserID, 10)
|
||||
pos, ok := m.readPositions[key]
|
||||
if !ok {
|
||||
pos = &ChatReadPosition{
|
||||
SessionID: req.SessionID,
|
||||
UserID: req.UserID,
|
||||
LastReadMessageID: 0,
|
||||
DismissedUnreadIDs: []int64{},
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
m.readPositions[key] = pos
|
||||
}
|
||||
|
||||
if req.AdvanceTo > pos.LastReadMessageID {
|
||||
pos.LastReadMessageID = req.AdvanceTo
|
||||
}
|
||||
|
||||
for _, id := range req.ReadMessageIDs {
|
||||
if id > pos.LastReadMessageID {
|
||||
pos.DismissedUnreadIDs = append(pos.DismissedUnreadIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取此会话中大于 baseline 的现有有效消息 ID 列表
|
||||
var futureIDs []int64
|
||||
for _, msg := range m.messages[req.SessionID] {
|
||||
if msg.DeletedAt == nil && msg.ID > pos.LastReadMessageID {
|
||||
futureIDs = append(futureIDs, msg.ID)
|
||||
}
|
||||
}
|
||||
sort.Slice(futureIDs, func(i, j int) bool { return futureIDs[i] < futureIDs[j] })
|
||||
|
||||
// 执行自动压缩
|
||||
pos.LastReadMessageID, pos.DismissedUnreadIDs = compressDismissed(pos.LastReadMessageID, pos.DismissedUnreadIDs, futureIDs)
|
||||
pos.UpdatedAt = time.Now()
|
||||
|
||||
cp := *pos
|
||||
cp.DismissedUnreadIDs = append([]int64(nil), pos.DismissedUnreadIDs...)
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *ContextManager) markChatReadPG(ctx context.Context, req MarkChatReadRequest) (*ChatReadPosition, error) {
|
||||
tx, err := m.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx failed: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var pos ChatReadPosition
|
||||
row := tx.QueryRow(ctx, `
|
||||
SELECT session_id, user_id, last_read_message_id, dismissed_unread_ids, updated_at
|
||||
FROM chat_read_positions
|
||||
WHERE session_id = $1 AND user_id = $2
|
||||
FOR UPDATE
|
||||
`, req.SessionID, req.UserID)
|
||||
|
||||
if err := row.Scan(&pos.SessionID, &pos.UserID, &pos.LastReadMessageID, &pos.DismissedUnreadIDs, &pos.UpdatedAt); err != nil {
|
||||
pos = ChatReadPosition{
|
||||
SessionID: req.SessionID,
|
||||
UserID: req.UserID,
|
||||
LastReadMessageID: 0,
|
||||
DismissedUnreadIDs: []int64{},
|
||||
}
|
||||
}
|
||||
|
||||
if req.AdvanceTo > pos.LastReadMessageID {
|
||||
pos.LastReadMessageID = req.AdvanceTo
|
||||
}
|
||||
|
||||
for _, id := range req.ReadMessageIDs {
|
||||
if id > pos.LastReadMessageID {
|
||||
pos.DismissedUnreadIDs = append(pos.DismissedUnreadIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
// 查询 DB 中大于当前 baseline 的消息 ID
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id FROM chat_messages
|
||||
WHERE session_id = $1 AND deleted_at IS NULL AND id > $2
|
||||
ORDER BY id ASC
|
||||
`, req.SessionID, pos.LastReadMessageID)
|
||||
var futureIDs []int64
|
||||
if err == nil {
|
||||
for rows.Next() {
|
||||
var mid int64
|
||||
if err := rows.Scan(&mid); err == nil {
|
||||
futureIDs = append(futureIDs, mid)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
pos.LastReadMessageID, pos.DismissedUnreadIDs = compressDismissed(pos.LastReadMessageID, pos.DismissedUnreadIDs, futureIDs)
|
||||
pos.UpdatedAt = time.Now()
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO chat_read_positions (session_id, user_id, last_read_message_id, dismissed_unread_ids, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (session_id, user_id)
|
||||
DO UPDATE SET
|
||||
last_read_message_id = EXCLUDED.last_read_message_id,
|
||||
dismissed_unread_ids = EXCLUDED.dismissed_unread_ids,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`, pos.SessionID, pos.UserID, pos.LastReadMessageID, pos.DismissedUnreadIDs, pos.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert chat_read_positions failed: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit tx failed: %w", err)
|
||||
}
|
||||
|
||||
return &pos, nil
|
||||
}
|
||||
|
||||
// compressDismissed 自动压缩算法:当离散消费集合与 baseline 连续时,推进 baseline 并裁切数组
|
||||
func compressDismissed(baseline int64, dismissed []int64, futureMsgIDs []int64) (int64, []int64) {
|
||||
if len(dismissed) == 0 {
|
||||
return baseline, []int64{}
|
||||
}
|
||||
|
||||
dMap := make(map[int64]bool, len(dismissed))
|
||||
for _, id := range dismissed {
|
||||
if id > baseline {
|
||||
dMap[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 沿现有消息列表升序尝试推进连续基线
|
||||
for _, mid := range futureMsgIDs {
|
||||
if mid <= baseline {
|
||||
continue
|
||||
}
|
||||
if dMap[mid] {
|
||||
baseline = mid
|
||||
delete(dMap, mid)
|
||||
} else {
|
||||
// 遇到第一条未消费的消息断点,停止推进连续基线
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var newDismissed []int64
|
||||
for id := range dMap {
|
||||
if id > baseline {
|
||||
newDismissed = append(newDismissed, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(newDismissed, func(i, j int) bool { return newDismissed[i] < newDismissed[j] })
|
||||
|
||||
return baseline, newDismissed
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package ctxmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompressDismissed(t *testing.T) {
|
||||
futureIDs := []int64{10, 11, 12, 13, 14}
|
||||
|
||||
// 1. 倒序读第 14 条 (最高一条)
|
||||
b1, d1 := compressDismissed(9, []int64{14}, futureIDs)
|
||||
if b1 != 9 {
|
||||
t.Errorf("expected baseline 9, got %d", b1)
|
||||
}
|
||||
if len(d1) != 1 || d1[0] != 14 {
|
||||
t.Errorf("expected dismissed [14], got %v", d1)
|
||||
}
|
||||
|
||||
// 2. 读第 10 条 (与 baseline 9 连续)
|
||||
b2, d2 := compressDismissed(9, []int64{10, 14}, futureIDs)
|
||||
if b2 != 10 {
|
||||
t.Errorf("expected baseline 10, got %d", b2)
|
||||
}
|
||||
if len(d2) != 1 || d2[0] != 14 {
|
||||
t.Errorf("expected dismissed [14], got %v", d2)
|
||||
}
|
||||
|
||||
// 3. 补齐 11, 12, 13,全连续收敛
|
||||
b3, d3 := compressDismissed(10, []int64{11, 12, 13, 14}, futureIDs)
|
||||
if b3 != 14 {
|
||||
t.Errorf("expected baseline 14, got %d", b3)
|
||||
}
|
||||
if len(d3) != 0 {
|
||||
t.Errorf("expected empty dismissed, got %v", d3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatUnreadLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
|
||||
|
||||
sessID := "sess_unread_test_01"
|
||||
userID := int64(1001)
|
||||
|
||||
// 1. 会话尚无消息时,首次查询返回 0 未读
|
||||
sum1, err := mgr.GetChatUnreadSummary(ctx, sessID, userID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChatUnreadSummary failed: %v", err)
|
||||
}
|
||||
if sum1.Total != 0 {
|
||||
t.Errorf("expected 0 unread on empty session, got %d", sum1.Total)
|
||||
}
|
||||
|
||||
// 2. 写入初始历史消息 2 条
|
||||
_ = mgr.RecordUserMessage(ctx, sessID, userID, "user", "你好", "CASUAL_CHAT", "", nil)
|
||||
_ = mgr.RecordUserMessage(ctx, sessID, 0, "assistant", "你好!有什么我可以帮您的?", "CASUAL_CHAT", "", nil)
|
||||
|
||||
// 首次进入会话,初始化默认全部已读
|
||||
posInit, err := mgr.GetChatReadPosition(ctx, sessID, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChatReadPosition failed: %v", err)
|
||||
}
|
||||
if posInit == nil {
|
||||
// 触发初始化
|
||||
sumInit, _ := mgr.GetChatUnreadSummary(ctx, sessID, userID, 0)
|
||||
if sumInit.Total != 0 {
|
||||
t.Errorf("expected 0 unread on initial load, got %d", sumInit.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 新收到来自 assistant 的 2 条消息
|
||||
_ = mgr.RecordUserMessage(ctx, sessID, 0, "assistant", "更新文档进展", "QUERY_PROGRESS", "", nil)
|
||||
_ = mgr.RecordUserMessage(ctx, sessID, 0, "assistant", "请确认方案卡片", "DOCGOV_SUBMIT_REVIEW", "", nil)
|
||||
|
||||
// 以及用户自己发出的 1 条消息 (不能被算作未读)
|
||||
_ = mgr.RecordUserMessage(ctx, sessID, userID, "user", "我看看", "CASUAL_CHAT", "", nil)
|
||||
|
||||
sum2, err := mgr.GetChatUnreadSummary(ctx, sessID, userID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChatUnreadSummary failed: %v", err)
|
||||
}
|
||||
if sum2.Total != 2 {
|
||||
t.Fatalf("expected 2 unread assistant messages, got %d", sum2.Total)
|
||||
}
|
||||
if len(sum2.UnreadMessageIDs) != 2 {
|
||||
t.Fatalf("expected 2 unread IDs, got %v", sum2.UnreadMessageIDs)
|
||||
}
|
||||
newestUnreadID := sum2.NextMessageID
|
||||
if newestUnreadID != sum2.UnreadMessageIDs[0] {
|
||||
t.Errorf("expected NextMessageID to match first unread ID %d, got %d", sum2.UnreadMessageIDs[0], newestUnreadID)
|
||||
}
|
||||
|
||||
// 4. 倒序消费:先标记最新的一条已读 (逐条消费循环第一步)
|
||||
posAfterFirst, err := mgr.MarkChatRead(ctx, MarkChatReadRequest{
|
||||
SessionID: sessID,
|
||||
UserID: userID,
|
||||
ReadMessageIDs: []int64{newestUnreadID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MarkChatRead failed: %v", err)
|
||||
}
|
||||
if len(posAfterFirst.DismissedUnreadIDs) != 1 || posAfterFirst.DismissedUnreadIDs[0] != newestUnreadID {
|
||||
t.Errorf("expected dismissed [%d], got %v", newestUnreadID, posAfterFirst.DismissedUnreadIDs)
|
||||
}
|
||||
|
||||
// 再次查询未读,还剩 1 条
|
||||
sum3, err := mgr.GetChatUnreadSummary(ctx, sessID, userID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChatUnreadSummary failed: %v", err)
|
||||
}
|
||||
if sum3.Total != 1 {
|
||||
t.Fatalf("expected 1 unread remaining, got %d", sum3.Total)
|
||||
}
|
||||
remainingID := sum3.NextMessageID
|
||||
|
||||
// 5. 消费最后一条未读,此时触发与 baseline 连续的自动压缩
|
||||
posAfterSecond, err := mgr.MarkChatRead(ctx, MarkChatReadRequest{
|
||||
SessionID: sessID,
|
||||
UserID: userID,
|
||||
ReadMessageIDs: []int64{remainingID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("MarkChatRead second step failed: %v", err)
|
||||
}
|
||||
if len(posAfterSecond.DismissedUnreadIDs) != 0 {
|
||||
t.Errorf("expected dismissed array to be compressed to empty, got %v", posAfterSecond.DismissedUnreadIDs)
|
||||
}
|
||||
|
||||
// 全部已读
|
||||
sumFinal, err := mgr.GetChatUnreadSummary(ctx, sessID, userID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChatUnreadSummary failed: %v", err)
|
||||
}
|
||||
if sumFinal.Total != 0 {
|
||||
t.Errorf("expected 0 unread after all consumed, got %d", sumFinal.Total)
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,7 @@ func TestListChatCategories_MemoryMode(t *testing.T) {
|
||||
}
|
||||
|
||||
// 按 count DESC 排序,task 应排在首位 (count=2)
|
||||
if stats[0].Category != "task" || stats[0].Count != 2 || stats[0].Label != "任务驱动" {
|
||||
if stats[0].Category != "task" || stats[0].Count != 2 || stats[0].Label != "任务执行" {
|
||||
t.Errorf("expected first category to be task (count=2), got %+v", stats[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type ContextManager struct {
|
||||
memoryChunks []MemoryChunkItem // 语义记忆切片缓存
|
||||
summaries map[string]*ChatSummary // session_id -> rolling summary
|
||||
digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest
|
||||
readPositions map[string]*ChatReadPosition // session_id:user_id -> read position
|
||||
cache *MultiLevelCache // 多级统一缓存系统 (C1-C5)
|
||||
nextActivityID int64
|
||||
nextMessageID int64
|
||||
@@ -55,6 +56,7 @@ func NewContextManager(pool *pgxpool.Pool, cas *storage.CAS, decayCfg TimeDecayC
|
||||
memoryChunks: make([]MemoryChunkItem, 0),
|
||||
summaries: make(map[string]*ChatSummary),
|
||||
digests: make(map[string]map[string]*DailyDigest),
|
||||
readPositions: make(map[string]*ChatReadPosition),
|
||||
cache: NewMultiLevelCache(),
|
||||
nextActivityID: 1,
|
||||
nextMessageID: 1,
|
||||
|
||||
@@ -31,6 +31,10 @@ const (
|
||||
IntentQueryUserRights IntentType = "QUERY_USER_RIGHTS"
|
||||
IntentQueryRejectReason IntentType = "QUERY_REJECT_REASON" // 查询受控文档驳回/退回原因
|
||||
|
||||
// 协同拍板与阶段执行动作
|
||||
IntentConfirmAction IntentType = "CONFIRM_ACTION" // 用户拍板确认交互卡片
|
||||
IntentExecuteStageAdvance IntentType = "EXECUTE_STAGE_ADVANCE" // 推进工作流阶段执行
|
||||
|
||||
// 安全与权限红线拦截
|
||||
IntentSecurityForbidden IntentType = "SECURITY_FORBIDDEN"
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
@@ -50,7 +51,7 @@ func TestServer_ChatUnreadAndExportAPIs(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("GET /api/v1/chat/unread 统计 since_message_id 之后的未读", func(t *testing.T) {
|
||||
// since_message_id=id2, 应统计 id3 与 id4 (均为 user 消息)
|
||||
// since_message_id=id2, 应统计 id3 与 id4
|
||||
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/unread?session_id=%s&since_message_id=%d", sessID, id2), nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
@@ -59,24 +60,47 @@ func TestServer_ChatUnreadAndExportAPIs(t *testing.T) {
|
||||
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
type unreadItem struct {
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
LastMessageID int64 `json:"last_message_id"`
|
||||
}
|
||||
var items []unreadItem
|
||||
if err := json.NewDecoder(w.Body).Decode(&items); err != nil {
|
||||
t.Fatalf("decode unread items failed: %v", err)
|
||||
var summary ctxmgr.ChatUnreadSummary
|
||||
if err := json.NewDecoder(w.Body).Decode(&summary); err != nil {
|
||||
t.Fatalf("decode unread summary failed: %v", err)
|
||||
}
|
||||
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 unread day bucket, got %d", len(items))
|
||||
if summary.Total != 2 {
|
||||
t.Errorf("expected total 2, got %d", summary.Total)
|
||||
}
|
||||
if items[0].Date != dayKey2 {
|
||||
t.Errorf("expected date %s, got %s", dayKey2, items[0].Date)
|
||||
if len(summary.Items) != 1 {
|
||||
t.Fatalf("expected 1 unread day bucket, got %d", len(summary.Items))
|
||||
}
|
||||
if items[0].Count != 2 {
|
||||
t.Errorf("expected count 2, got %d", items[0].Count)
|
||||
if summary.Items[0].Date != dayKey2 {
|
||||
t.Errorf("expected date %s, got %s", dayKey2, summary.Items[0].Date)
|
||||
}
|
||||
if summary.Items[0].Count != 2 {
|
||||
t.Errorf("expected count 2, got %d", summary.Items[0].Count)
|
||||
}
|
||||
if summary.NextMessageID == 0 {
|
||||
t.Errorf("expected NextMessageID > 0, got %d", summary.NextMessageID)
|
||||
}
|
||||
|
||||
// 消费最新一条未读 (POST /api/v1/chat/read)
|
||||
readBody := fmt.Sprintf(`{"session_id":"%s","user_id":0,"read_message_ids":[%d]}`, sessID, summary.NextMessageID)
|
||||
readReq := httptest.NewRequest(http.MethodPost, "/api/v1/chat/read", strings.NewReader(readBody))
|
||||
readReq.Header.Set("Content-Type", "application/json")
|
||||
readW := httptest.NewRecorder()
|
||||
handler.ServeHTTP(readW, readReq)
|
||||
|
||||
if readW.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK on /api/v1/chat/read, got %d: %s", readW.Code, readW.Body.String())
|
||||
}
|
||||
|
||||
// 再次获取未读,数量应减为 1
|
||||
req2 := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/chat/unread?session_id=%s&since_message_id=%d", sessID, id2), nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w2, req2)
|
||||
|
||||
var summary2 ctxmgr.ChatUnreadSummary
|
||||
_ = json.NewDecoder(w2.Body).Decode(&summary2)
|
||||
if summary2.Total != 1 {
|
||||
t.Errorf("expected total 1 after consuming one unread, got %d", summary2.Total)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
package server
|
||||
|
||||
// chat_unread_api.go — P3 未读消息 API
|
||||
// chat_unread_api.go — P3 / Step 2 双轨制未读消息与位点推进 API
|
||||
//
|
||||
// GET /api/v1/chat/unread?session_id=...&since_message_id=...
|
||||
//
|
||||
// 返回 since_message_id 之后的新消息按日聚合未读数。
|
||||
// 内存模式(MemoryStore)下直接按消息列表计数;
|
||||
// DB 模式查询 chat_messages 表。
|
||||
// GET /api/v1/chat/unread?session_id=...&user_id=...&since_message_id=...
|
||||
// POST /api/v1/chat/read
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type unreadItem struct {
|
||||
Date string `json:"date"`
|
||||
Count int `json:"count"`
|
||||
LastMessageID int64 `json:"last_message_id"`
|
||||
}
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
)
|
||||
|
||||
func (s *Server) handleChatUnread(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := r.URL.Query().Get("session_id")
|
||||
@@ -26,66 +20,77 @@ func (s *Server) handleChatUnread(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sinceStr := r.URL.Query().Get("since_message_id")
|
||||
var userID int64
|
||||
if uStr := r.URL.Query().Get("user_id"); uStr != "" {
|
||||
if uid, err := strconv.ParseInt(uStr, 10, 64); err == nil {
|
||||
userID = uid
|
||||
}
|
||||
}
|
||||
|
||||
var sinceID int64
|
||||
if sinceStr != "" {
|
||||
id, err := strconv.ParseInt(sinceStr, 10, 64)
|
||||
if err == nil {
|
||||
if sinceStr := r.URL.Query().Get("since_message_id"); sinceStr != "" {
|
||||
if id, err := strconv.ParseInt(sinceStr, 10, 64); err == nil {
|
||||
sinceID = id
|
||||
}
|
||||
}
|
||||
|
||||
if s.svc == nil || s.svc.ContextManager() == nil {
|
||||
writeJSONResponse(w, http.StatusOK, []unreadItem{})
|
||||
return
|
||||
}
|
||||
|
||||
msgs, err := s.svc.ContextManager().GetChatHistory(r.Context(), sessionID, 3000, 0)
|
||||
if err != nil {
|
||||
writeJSONResponse(w, http.StatusOK, []unreadItem{})
|
||||
return
|
||||
}
|
||||
|
||||
type dayAgg struct {
|
||||
count int
|
||||
lastMessageID int64
|
||||
}
|
||||
dayMap := make(map[string]*dayAgg)
|
||||
var dayOrder []string
|
||||
|
||||
for _, msg := range msgs {
|
||||
if msg.ID == 0 || msg.ID <= sinceID {
|
||||
continue
|
||||
}
|
||||
if msg.Role != "user" {
|
||||
continue
|
||||
}
|
||||
dayKey := msg.DayKey
|
||||
if dayKey == "" && !msg.CreatedAt.IsZero() {
|
||||
dayKey = msg.CreatedAt.Format("2006-01-02")
|
||||
}
|
||||
if dayKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := dayMap[dayKey]; !ok {
|
||||
dayMap[dayKey] = &dayAgg{}
|
||||
dayOrder = append(dayOrder, dayKey)
|
||||
}
|
||||
dayMap[dayKey].count++
|
||||
if msg.ID > dayMap[dayKey].lastMessageID {
|
||||
dayMap[dayKey].lastMessageID = msg.ID
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]unreadItem, 0, len(dayOrder))
|
||||
for _, dk := range dayOrder {
|
||||
agg := dayMap[dk]
|
||||
items = append(items, unreadItem{
|
||||
Date: dk,
|
||||
Count: agg.count,
|
||||
LastMessageID: agg.lastMessageID,
|
||||
writeJSONResponse(w, http.StatusOK, ctxmgr.ChatUnreadSummary{
|
||||
Total: 0,
|
||||
NextMessageID: 0,
|
||||
UnreadMessageIDs: []int64{},
|
||||
Items: []ctxmgr.UnreadDayItem{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(w, http.StatusOK, items)
|
||||
summary, err := s.svc.ContextManager().GetChatUnreadSummary(r.Context(), sessionID, userID, sinceID)
|
||||
if err != nil {
|
||||
writeJSONResponse(w, http.StatusOK, ctxmgr.ChatUnreadSummary{
|
||||
Total: 0,
|
||||
NextMessageID: 0,
|
||||
UnreadMessageIDs: []int64{},
|
||||
Items: []ctxmgr.UnreadDayItem{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(w, http.StatusOK, summary)
|
||||
}
|
||||
|
||||
func (s *Server) handleChatRead(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req ctxmgr.MarkChatReadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.SessionID == "" {
|
||||
http.Error(w, "missing session_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if s.svc == nil || s.svc.ContextManager() == nil {
|
||||
http.Error(w, "context manager not initialized", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
pos, err := s.svc.ContextManager().MarkChatRead(r.Context(), req)
|
||||
if err != nil {
|
||||
http.Error(w, "mark chat read failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"session_id": pos.SessionID,
|
||||
"user_id": pos.UserID,
|
||||
"last_read_message_id": pos.LastReadMessageID,
|
||||
"dismissed_unread_ids": pos.DismissedUnreadIDs,
|
||||
"updated_at": pos.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("POST /api/v1/chat/messages/import", s.handleImportChatMessages)
|
||||
// P3: 未读提醒 & 聊天导出
|
||||
mux.HandleFunc("GET /api/v1/chat/unread", s.handleChatUnread)
|
||||
mux.HandleFunc("POST /api/v1/chat/read", s.handleChatRead)
|
||||
mux.HandleFunc("GET /api/v1/chat/export/messages", s.handleChatExportMessages)
|
||||
mux.HandleFunc("GET /api/v1/context/cases/recommend", s.handleRecommendCases)
|
||||
|
||||
|
||||
+66
-20
@@ -83,9 +83,10 @@ import { CategoryFilterBar } from './components/history/CategoryFilterBar';
|
||||
import { HistoryCalendar } from './components/history/HistoryCalendar';
|
||||
import { ChatSearchModal } from './components/history/ChatSearchModal';
|
||||
import { DigestTimelineModal } from './components/history/DigestTimelineModal';
|
||||
import { navigateToAnchor, parseAnchorHash } from './engine/anchorNavigator';
|
||||
import { navigateToAnchor, parseAnchorHash, resolveTurnAnchorId } from './engine/anchorNavigator';
|
||||
import { ChatExportModal } from './components/history/ChatExportModal';
|
||||
import { useUnreadMarks } from './hooks/useUnreadMarks';
|
||||
import { CategoryDock } from './components/chat/CategoryDock';
|
||||
import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
|
||||
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
|
||||
|
||||
@@ -767,7 +768,13 @@ export default function App() {
|
||||
const [activeQuotes, setActiveQuotes] = useState<QuotedMessage[]>([]);
|
||||
|
||||
// P3: 未读消息轮询与角标
|
||||
const { unreadMarks, unreadTotal } = useUnreadMarks(activeSessionId);
|
||||
const {
|
||||
unreadMarks,
|
||||
unreadTotal,
|
||||
unreadMessageIds,
|
||||
isAllReadCompleted,
|
||||
consumeNextUnread,
|
||||
} = useUnreadMarks(activeSessionId, currentUser?.id);
|
||||
|
||||
// 会话切换时加载分类与日期统计
|
||||
useEffect(() => {
|
||||
@@ -1942,6 +1949,15 @@ export default function App() {
|
||||
categories={categoryStats}
|
||||
selectedCategory={chatWindow.selectedCategory}
|
||||
onSelectCategory={chatWindow.setSelectedCategory}
|
||||
onNavigateToCategory={(_cat, lastMessageId) => {
|
||||
if (lastMessageId && activeSessionId) {
|
||||
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages);
|
||||
navigateToAnchor(
|
||||
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 },
|
||||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||||
);
|
||||
}
|
||||
}}
|
||||
onToggleCalendar={() => setIsCalendarOpen((prev) => !prev)}
|
||||
isCalendarOpen={isCalendarOpen}
|
||||
onOpenSearch={() => setIsSearchOpen(true)}
|
||||
@@ -1994,24 +2010,54 @@ export default function App() {
|
||||
dayStack={chatWindow.displayDayStack}
|
||||
/>
|
||||
)}
|
||||
<MessageStream
|
||||
dayStack={chatWindow.displayDayStack}
|
||||
isLoadingOlder={chatWindow.phase === 'loading_older'}
|
||||
hasMoreBefore={chatWindow.hasMoreBefore}
|
||||
onLoadOlder={chatWindow.loadOlder}
|
||||
onLoadFullDay={chatWindow.loadFullDay}
|
||||
onOpenCase={handleSelectDoc}
|
||||
onCardAction={handleCardAction}
|
||||
onErrorAction={handleErrorAction}
|
||||
onQuote={handleQuoteMessage}
|
||||
onNavigateToAnchor={(targetAnchor) => {
|
||||
navigateToAnchor(
|
||||
{ sessionId: activeSessionId, messageId: targetAnchor },
|
||||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||||
);
|
||||
}}
|
||||
selectedCategory={chatWindow.selectedCategory}
|
||||
/>
|
||||
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<MessageStream
|
||||
dayStack={chatWindow.displayDayStack}
|
||||
isLoadingOlder={chatWindow.phase === 'loading_older'}
|
||||
hasMoreBefore={chatWindow.hasMoreBefore}
|
||||
onLoadOlder={chatWindow.loadOlder}
|
||||
onLoadFullDay={chatWindow.loadFullDay}
|
||||
onOpenCase={handleSelectDoc}
|
||||
onCardAction={handleCardAction}
|
||||
onErrorAction={handleErrorAction}
|
||||
onQuote={handleQuoteMessage}
|
||||
onNavigateToAnchor={(targetAnchor) => {
|
||||
navigateToAnchor(
|
||||
{ sessionId: activeSessionId, messageId: targetAnchor },
|
||||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||||
);
|
||||
}}
|
||||
selectedCategory={chatWindow.selectedCategory}
|
||||
unreadMessageIds={unreadMessageIds}
|
||||
/>
|
||||
<CategoryDock
|
||||
categories={categoryStats}
|
||||
activeCategory={chatWindow.selectedCategory}
|
||||
messages={chatWindow.allMessages}
|
||||
unreadTotal={unreadTotal}
|
||||
unreadMessageIds={unreadMessageIds}
|
||||
isAllReadCompleted={isAllReadCompleted}
|
||||
onConsumeNextUnread={async () => {
|
||||
const targetId = await consumeNextUnread();
|
||||
if (targetId && activeSessionId) {
|
||||
navigateToAnchor(
|
||||
{ sessionId: activeSessionId, messageId: targetId, highlightMs: 2000 },
|
||||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||||
);
|
||||
}
|
||||
}}
|
||||
onNavigateToCategory={(_cat, lastMessageId) => {
|
||||
if (lastMessageId && activeSessionId) {
|
||||
const anchorId = resolveTurnAnchorId(lastMessageId, chatWindow.allMessages);
|
||||
navigateToAnchor(
|
||||
{ sessionId: activeSessionId, messageId: anchorId, highlightMs: 2000 },
|
||||
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
|
||||
);
|
||||
}
|
||||
}}
|
||||
onSelectCategory={chatWindow.setSelectedCategory}
|
||||
/>
|
||||
</div>
|
||||
{pendingProposal && (
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<ProposalConfirmCard
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { CategoryDock } from './CategoryDock';
|
||||
import { ChatCategoryStat } from '../../types/workbench';
|
||||
|
||||
test('CategoryDock: 渲染顶部 🔔 未读提醒项及角标数字', () => {
|
||||
const mockCategories: ChatCategoryStat[] = [
|
||||
{ category: 'milestone', label: '关键节点', count: 3, first_message_id: 10, last_message_id: 15 },
|
||||
{ category: 'review', label: '审批流转', count: 2, first_message_id: 16, last_message_id: 20 },
|
||||
{ category: 'task', label: '任务执行', count: 5, first_message_id: 21, last_message_id: 30 },
|
||||
{ category: 'file', label: '文件收发', count: 1, first_message_id: 31, last_message_id: 32 },
|
||||
{ category: 'consult', label: '业务咨询', count: 4, first_message_id: 33, last_message_id: 40 },
|
||||
{ category: 'casual', label: '日常闲聊', count: 8, first_message_id: 41, last_message_id: 50 },
|
||||
{ category: 'system', label: '系统通知', count: 1, first_message_id: 51, last_message_id: 52 },
|
||||
{ category: 'uncategorized', label: '其他', count: 0, first_message_id: 0, last_message_id: 0 },
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(CategoryDock, {
|
||||
categories: mockCategories,
|
||||
unreadTotal: 7,
|
||||
onConsumeNextUnread: () => {},
|
||||
onNavigateToCategory: () => {},
|
||||
})
|
||||
);
|
||||
|
||||
// 包含未读数字 7
|
||||
assert.ok(html.includes('7'), 'must render unread count badge');
|
||||
// 包含定稿分类名称
|
||||
assert.ok(html.includes('关键节点'), 'must render milestone label');
|
||||
assert.ok(html.includes('任务执行'), 'must render task label');
|
||||
assert.ok(html.includes('审批流转'), 'must render review label');
|
||||
assert.ok(html.includes('业务咨询'), 'must render consult label');
|
||||
assert.ok(html.includes('文件收发'), 'must render file label');
|
||||
assert.ok(html.includes('日常闲聊'), 'must render casual label');
|
||||
assert.ok(html.includes('系统通知'), 'must render system label');
|
||||
});
|
||||
|
||||
test('CategoryDock: isAllReadCompleted 为 true 时渲染完成态提示', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(CategoryDock, {
|
||||
categories: [],
|
||||
unreadTotal: 0,
|
||||
isAllReadCompleted: true,
|
||||
onConsumeNextUnread: () => {},
|
||||
onNavigateToCategory: () => {},
|
||||
})
|
||||
);
|
||||
|
||||
assert.ok(html.includes('全部读完 ✓'), 'must render all read completed text');
|
||||
});
|
||||
|
||||
test('CategoryDock: 0 未读时不渲染红色未读角标', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(CategoryDock, {
|
||||
categories: [],
|
||||
unreadTotal: 0,
|
||||
isAllReadCompleted: false,
|
||||
onConsumeNextUnread: () => {},
|
||||
onNavigateToCategory: () => {},
|
||||
})
|
||||
);
|
||||
|
||||
assert.ok(!html.includes('box-shadow:0 2px 6px rgba(239, 68, 68, 0.6)'), 'must not render red badge box-shadow');
|
||||
assert.ok(html.includes('无新未读消息'), 'must show no unread tooltip');
|
||||
});
|
||||
|
||||
test('CategoryDock: 分类项包含依次跳转与遍历提示信息', () => {
|
||||
const mockCategories: ChatCategoryStat[] = [
|
||||
{ category: 'task', label: '任务执行', count: 3, first_message_id: 10, last_message_id: 30 },
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(CategoryDock, {
|
||||
categories: mockCategories,
|
||||
unreadTotal: 0,
|
||||
activeCategory: 'task',
|
||||
onConsumeNextUnread: () => {},
|
||||
onNavigateToCategory: () => {},
|
||||
})
|
||||
);
|
||||
|
||||
assert.ok(html.includes('再次点击可向前遍历'), 'must include forward traversal hint in tooltip');
|
||||
});
|
||||
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* CategoryDock — 右侧悬浮分类锚点坞 (Spec §3.2 & §4.4)
|
||||
*
|
||||
* 1. 顶部 🔔 未读提醒项:角标显示未读数,点击执行 consumeNextUnread 逐条消费闭环
|
||||
* 2. 分类锚点:macOS 放大算法 + Mini-Index 悬浮卡片 + 视口中心分类感知
|
||||
* 3. 点击直达:一键平滑/瞬时锚定至该分类最新一条消息
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
Check,
|
||||
Flag,
|
||||
CheckSquare,
|
||||
Play,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
Info,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { MessageCategory, ChatCategoryStat, ChatMessage } from '../../types/workbench';
|
||||
import { resolveTurnAnchorId } from '../../engine/anchorNavigator';
|
||||
|
||||
export interface CategoryDockProps {
|
||||
categories: ChatCategoryStat[];
|
||||
activeCategory?: MessageCategory | 'all';
|
||||
messages?: ChatMessage[];
|
||||
unreadTotal: number;
|
||||
unreadMessageIds?: number[];
|
||||
isAllReadCompleted?: boolean;
|
||||
onConsumeNextUnread: () => void;
|
||||
onNavigateToCategory: (category: MessageCategory, lastMessageId?: number) => void;
|
||||
onSelectCategory?: (category: MessageCategory | 'all') => void;
|
||||
}
|
||||
|
||||
interface DockCategoryConfig {
|
||||
key: MessageCategory;
|
||||
defaultLabel: string;
|
||||
desc: string;
|
||||
icon: React.FC<{ size?: number; color?: string; className?: string }>;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const DOCK_CATEGORIES: DockCategoryConfig[] = [
|
||||
{
|
||||
key: 'milestone',
|
||||
defaultLabel: '关键节点',
|
||||
desc: '里程碑阶段标记与立项拍板决策',
|
||||
icon: Flag,
|
||||
color: '#f59e0b',
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
defaultLabel: '审批流转',
|
||||
desc: '文档审核、驳回及待确认交互卡片',
|
||||
icon: CheckSquare,
|
||||
color: '#4f6bff',
|
||||
},
|
||||
{
|
||||
key: 'task',
|
||||
defaultLabel: '任务执行',
|
||||
desc: '推进阶段及受控交付物任务生成',
|
||||
icon: Play,
|
||||
color: '#10b981',
|
||||
},
|
||||
{
|
||||
key: 'file',
|
||||
defaultLabel: '文件收发',
|
||||
desc: '文档/规范文件附件上传及下载',
|
||||
icon: FileText,
|
||||
color: '#a855f7',
|
||||
},
|
||||
{
|
||||
key: 'consult',
|
||||
defaultLabel: '业务咨询',
|
||||
desc: '法规查询、进度研判与参谋建议',
|
||||
icon: HelpCircle,
|
||||
color: '#06b6d4',
|
||||
},
|
||||
{
|
||||
key: 'casual',
|
||||
defaultLabel: '日常闲聊',
|
||||
desc: '常规问候与通用协同对话',
|
||||
icon: MessageSquare,
|
||||
color: '#94a3b8',
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
defaultLabel: '系统通知',
|
||||
desc: '系统提示与安全红线拦截',
|
||||
icon: Info,
|
||||
color: '#64748b',
|
||||
},
|
||||
{
|
||||
key: 'uncategorized',
|
||||
defaultLabel: '其他',
|
||||
desc: '未归类自由消息',
|
||||
icon: Layers,
|
||||
color: '#64748b',
|
||||
},
|
||||
];
|
||||
|
||||
export const CategoryDock: React.FC<CategoryDockProps> = ({
|
||||
categories,
|
||||
activeCategory = 'all',
|
||||
messages,
|
||||
unreadTotal,
|
||||
isAllReadCompleted = false,
|
||||
onConsumeNextUnread,
|
||||
onNavigateToCategory,
|
||||
onSelectCategory,
|
||||
}) => {
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const [cycleIndexMap, setCycleIndexMap] = useState<Record<string, number>>({});
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 快速映射后端分类统计
|
||||
const statMap = useMemo(() => {
|
||||
const map = new Map<string, ChatCategoryStat>();
|
||||
for (const c of categories) {
|
||||
map.set(c.category, c);
|
||||
}
|
||||
return map;
|
||||
}, [categories]);
|
||||
|
||||
// 提取各分类的所有已知消息 ID(方案 A: 统一归一化为提问轮次锚点,按时间/ID 升序排列)
|
||||
const messageIdsByCategory = useMemo(() => {
|
||||
const map: Record<string, number[]> = {};
|
||||
if (messages && messages.length > 0) {
|
||||
for (const msg of messages) {
|
||||
const cat = msg.category || 'uncategorized';
|
||||
if (!map[cat]) map[cat] = [];
|
||||
const rawId = msg.serverId || (typeof msg.id === 'number' ? msg.id : parseInt(String(msg.id), 10));
|
||||
if (rawId && !isNaN(rawId)) {
|
||||
const turnId = resolveTurnAnchorId(rawId, messages);
|
||||
map[cat].push(turnId);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 与 categories 中的 first/last_message_id 兜底对齐并去重升序
|
||||
for (const c of categories) {
|
||||
const existing = map[c.category] || [];
|
||||
const set = new Set<number>(existing);
|
||||
if (c.first_message_id) {
|
||||
set.add(resolveTurnAnchorId(c.first_message_id, messages));
|
||||
}
|
||||
if (c.last_message_id) {
|
||||
set.add(resolveTurnAnchorId(c.last_message_id, messages));
|
||||
}
|
||||
map[c.category] = Array.from(set).sort((a, b) => a - b);
|
||||
}
|
||||
return map;
|
||||
}, [messages, categories]);
|
||||
|
||||
// 计算 macOS 式放大系数
|
||||
const getScale = useCallback(
|
||||
(index: number) => {
|
||||
if (hoverIndex === null) return 1;
|
||||
const dist = Math.abs(index - hoverIndex);
|
||||
if (dist < 2.5) {
|
||||
return 1 + Math.max(0, 0.42 * (1 - dist / 2.5));
|
||||
}
|
||||
return 1;
|
||||
},
|
||||
[hoverIndex]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dockRef}
|
||||
onMouseLeave={() => setHoverIndex(null)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 35,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '6px 4px',
|
||||
borderRadius: '24px',
|
||||
background: 'rgba(23, 27, 38, 0.85)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
WebkitBackdropFilter: 'blur(16px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.38)',
|
||||
userSelect: 'none',
|
||||
transition: 'background 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{/* 1. 顶部 🔔 未读提醒入口 (Index 0) */}
|
||||
<div
|
||||
onMouseEnter={() => setHoverIndex(0)}
|
||||
onClick={onConsumeNextUnread}
|
||||
title={
|
||||
isAllReadCompleted
|
||||
? '全部读完 ✓'
|
||||
: unreadTotal > 0
|
||||
? `未读消息 (${unreadTotal} 条) · 点击逐条查看最新未读`
|
||||
: '无新未读消息'
|
||||
}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: unreadTotal > 0 ? 'pointer' : 'default',
|
||||
transform: `scale(${getScale(0)})`,
|
||||
transformOrigin: 'right center',
|
||||
transition: 'transform 0.12s cubic-bezier(0.2, 0, 0, 1), background 0.2s ease',
|
||||
background: isAllReadCompleted
|
||||
? 'rgba(16, 185, 129, 0.2)'
|
||||
: unreadTotal > 0
|
||||
? 'rgba(239, 68, 68, 0.15)'
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
{isAllReadCompleted ? (
|
||||
<Check size={16} color="var(--success, #10b981)" />
|
||||
) : (
|
||||
<Bell
|
||||
size={16}
|
||||
color={unreadTotal > 0 ? '#f87171' : 'var(--text-muted, #94a3b8)'}
|
||||
style={{
|
||||
animation: unreadTotal > 0 ? 'dock-bell-pulse 2s infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 红色未读角标 */}
|
||||
{unreadTotal > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-2px',
|
||||
right: '-2px',
|
||||
minWidth: '15px',
|
||||
height: '15px',
|
||||
padding: '0 3px',
|
||||
borderRadius: '9999px',
|
||||
background: '#ef4444',
|
||||
color: '#fff',
|
||||
fontSize: '9px',
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(239, 68, 68, 0.6)',
|
||||
}}
|
||||
>
|
||||
{unreadTotal > 99 ? '99+' : unreadTotal}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 悬停 Mini-Index 气泡 */}
|
||||
{hoverIndex === 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '42px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
whiteSpace: 'nowrap',
|
||||
background: 'rgba(15, 23, 42, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
|
||||
borderRadius: '8px',
|
||||
padding: '6px 10px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: '#fff' }}>
|
||||
{isAllReadCompleted
|
||||
? '全部读完 ✓'
|
||||
: unreadTotal > 0
|
||||
? `未读消息 (${unreadTotal} 条)`
|
||||
: '无新未读'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted, #94a3b8)', marginTop: '2px' }}>
|
||||
{unreadTotal > 0 ? '点击逐条定位并消除最新未读' : '会话消息均已阅读'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div
|
||||
style={{
|
||||
width: '18px',
|
||||
height: '1px',
|
||||
background: 'rgba(255, 255, 255, 0.12)',
|
||||
margin: '4px 0',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 2. 8 个分类锚点项 (Index 1..8) */}
|
||||
{DOCK_CATEGORIES.map((cfg, i) => {
|
||||
const itemIndex = i + 1;
|
||||
const stat = statMap.get(cfg.key);
|
||||
const count = stat?.count || 0;
|
||||
const displayLabel = stat?.label || cfg.defaultLabel;
|
||||
const targetIds = messageIdsByCategory[cfg.key] || (stat?.last_message_id ? [stat.last_message_id] : []);
|
||||
const hasMessages = count > 0 || targetIds.length > 0;
|
||||
const isActive = activeCategory === cfg.key;
|
||||
const scale = getScale(itemIndex);
|
||||
const Icon = cfg.icon;
|
||||
|
||||
const currentIndex = cycleIndexMap[cfg.key];
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 1. 激活并保持该分类筛选(Dock 作为锚点导航坞,绝不主动取消筛选)
|
||||
if (onSelectCategory) {
|
||||
onSelectCategory(cfg.key);
|
||||
}
|
||||
|
||||
if (targetIds.length === 0) return;
|
||||
|
||||
// 2. 计算跳转目标(循环遍历):
|
||||
// 首次进入该分类:直接跳到最新一条(末尾 index = targetIds.length - 1)
|
||||
// 再次点击同一分类:继续向前(更早一条)跳转;到达最老一条后循环回最新一条
|
||||
let nextIndex: number;
|
||||
if (!isActive || currentIndex === undefined) {
|
||||
nextIndex = targetIds.length - 1;
|
||||
} else {
|
||||
nextIndex = currentIndex - 1;
|
||||
if (nextIndex < 0) {
|
||||
nextIndex = targetIds.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
setCycleIndexMap((prev) => ({
|
||||
...prev,
|
||||
[cfg.key]: nextIndex,
|
||||
}));
|
||||
|
||||
const targetId = targetIds[nextIndex];
|
||||
if (targetId) {
|
||||
onNavigateToCategory(cfg.key, targetId);
|
||||
}
|
||||
};
|
||||
|
||||
const stepTooltip =
|
||||
targetIds.length > 1
|
||||
? isActive && currentIndex !== undefined
|
||||
? ` · 当前第 ${targetIds.length - currentIndex}/${targetIds.length} 条 · 再次点击跳转上一条`
|
||||
: ` · 点击直达最新一条(再次点击可向前遍历,共 ${targetIds.length} 条)`
|
||||
: ' · 点击定位至该条消息';
|
||||
|
||||
const tooltipText = `${displayLabel} · ${count} 条记录 · ${cfg.desc}${hasMessages ? stepTooltip : ''}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cfg.key}
|
||||
data-category={cfg.key}
|
||||
title={tooltipText}
|
||||
onMouseEnter={() => setHoverIndex(itemIndex)}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: hasMessages ? 'pointer' : 'default',
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'right center',
|
||||
transition: 'transform 0.12s cubic-bezier(0.2, 0, 0, 1), background 0.2s ease',
|
||||
opacity: hasMessages ? 1 : 0.32,
|
||||
background: isActive
|
||||
? 'rgba(79, 107, 255, 0.25)'
|
||||
: 'transparent',
|
||||
border: isActive ? '1px solid rgba(79, 107, 255, 0.6)' : '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
<Icon size={14} color={isActive ? '#fff' : cfg.color} />
|
||||
|
||||
{/* 数量角标微标 */}
|
||||
{count > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-1px',
|
||||
right: '-1px',
|
||||
fontSize: '8px',
|
||||
fontWeight: 600,
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
background: 'rgba(0, 0, 0, 0.4)',
|
||||
padding: '0 2px',
|
||||
borderRadius: '4px',
|
||||
lineHeight: '10px',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 悬停 Mini-Index 气泡 */}
|
||||
{hoverIndex === itemIndex && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '42px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
whiteSpace: 'nowrap',
|
||||
background: 'rgba(15, 23, 42, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4)',
|
||||
borderRadius: '8px',
|
||||
padding: '6px 10px',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Icon size={12} color={cfg.color} />
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: '#fff' }}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
padding: '0 4px',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{count} 条
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: 'var(--text-muted, #94a3b8)',
|
||||
marginTop: '3px',
|
||||
maxWidth: '180px',
|
||||
whiteSpace: 'normal',
|
||||
lineHeight: '1.3',
|
||||
}}
|
||||
>
|
||||
{cfg.desc}
|
||||
</div>
|
||||
{hasMessages && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
color: 'var(--brand-400, #6b84ff)',
|
||||
marginTop: '4px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{targetIds.length > 1
|
||||
? isActive && currentIndex !== undefined
|
||||
? `当前第 ${targetIds.length - currentIndex}/${targetIds.length} 条 · 点击跳上一条`
|
||||
: `点击依次查看该类消息 (共 ${targetIds.length} 条)`
|
||||
: `点击直达最新一条 (#${targetIds[0]})`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,8 @@ export interface MessageBubbleProps {
|
||||
onQuote?: (message: ChatMessage) => void;
|
||||
onNavigateToAnchor?: (messageId: number) => void;
|
||||
isCategoryDimmed?: boolean;
|
||||
/** 是否为未读消息(左侧橙色标记边框) */
|
||||
isUnread?: boolean;
|
||||
}
|
||||
|
||||
const ERROR_ACTION_LABEL: Record<'retry' | 'continue' | 'restart', string> = {
|
||||
@@ -39,6 +41,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
onQuote,
|
||||
onNavigateToAnchor,
|
||||
isCategoryDimmed = false,
|
||||
isUnread = false,
|
||||
}) => {
|
||||
const isUser = message.role === 'user';
|
||||
const status = message.status;
|
||||
@@ -76,7 +79,9 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
alignItems: isUser ? 'flex-end' : 'flex-start',
|
||||
marginBottom: '16px',
|
||||
padding: '0 16px',
|
||||
transition: 'opacity 0.2s ease',
|
||||
paddingLeft: isUnread ? '12px' : '16px',
|
||||
borderLeft: isUnread ? '3px solid #f97316' : '3px solid transparent',
|
||||
transition: 'opacity 0.2s ease, border-left-color 0.3s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface MessageStreamProps {
|
||||
onQuote?: (message: ChatMessage) => void;
|
||||
onNavigateToAnchor?: (messageId: number) => void;
|
||||
selectedCategory?: MessageCategory | 'all';
|
||||
/** P3 / Step 5: 未读消息 ID 集合 */
|
||||
unreadMessageIds?: number[];
|
||||
}
|
||||
|
||||
export const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
@@ -37,11 +39,14 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
onQuote,
|
||||
onNavigateToAnchor,
|
||||
selectedCategory = 'all',
|
||||
unreadMessageIds = [],
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isInitialMountRef = useRef<boolean>(true);
|
||||
const prevMsgCountRef = useRef<number>(0);
|
||||
|
||||
const unreadSet = React.useMemo(() => new Set(unreadMessageIds), [unreadMessageIds]);
|
||||
|
||||
// 计算当前有效日分片集
|
||||
const activeBuckets: ChatDayBucket[] = React.useMemo(() => {
|
||||
if (dayStack && dayStack.length > 0) {
|
||||
@@ -240,6 +245,7 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
isCategoryDimmed={
|
||||
selectedCategory !== 'all' && msg.category !== selectedCategory
|
||||
}
|
||||
isUnread={msg.serverId ? unreadSet.has(msg.serverId) : false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ interface CategoryFilterBarProps {
|
||||
categories: ChatCategoryStat[];
|
||||
selectedCategory: MessageCategory | 'all';
|
||||
onSelectCategory: (cat: MessageCategory | 'all') => void;
|
||||
onNavigateToCategory?: (cat: MessageCategory | 'all', lastMessageId?: number) => void;
|
||||
onToggleCalendar?: () => void;
|
||||
isCalendarOpen?: boolean;
|
||||
onOpenSearch?: () => void;
|
||||
@@ -31,22 +32,23 @@ interface CategoryFilterBarProps {
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
string,
|
||||
{ label: string; icon: React.FC<{ size?: number; color?: string }>; color: string }
|
||||
{ label: string; icon: React.FC<{ size?: number; color?: string }>; color: string; desc: string }
|
||||
> = {
|
||||
milestone: { label: '里程碑', icon: Flag, color: 'var(--amber-500, #f59e0b)' },
|
||||
review: { label: '审批审查', icon: CheckSquare, color: 'var(--brand-500, #4f6bff)' },
|
||||
task: { label: '任务驱动', icon: Play, color: 'var(--success, #10b981)' },
|
||||
file: { label: '文档交付', icon: FileText, color: '#a855f7' },
|
||||
consult: { label: '业务咨询', icon: HelpCircle, color: '#06b6d4' },
|
||||
casual: { label: '日常协同', icon: MessageSquare, color: 'var(--text-muted, #94a3b8)' },
|
||||
system: { label: '系统通知', icon: Info, color: 'var(--text-faint, #64748b)' },
|
||||
uncategorized: { label: '未分类', icon: Layers, color: 'var(--text-faint, #64748b)' },
|
||||
milestone: { label: '关键节点', icon: Flag, color: 'var(--amber-500, #f59e0b)', desc: '里程碑阶段标记与立项拍板决策' },
|
||||
review: { label: '审批流转', icon: CheckSquare, color: 'var(--brand-500, #4f6bff)', desc: '文档审核、驳回及待确认交互卡片' },
|
||||
task: { label: '任务执行', icon: Play, color: 'var(--success, #10b981)', desc: '推进阶段及受控交付物任务生成' },
|
||||
file: { label: '文件收发', icon: FileText, color: '#a855f7', desc: '文档/规范文件附件上传及下载' },
|
||||
consult: { label: '业务咨询', icon: HelpCircle, color: '#06b6d4', desc: '法规查询、进度研判与参谋建议' },
|
||||
casual: { label: '日常闲聊', icon: MessageSquare, color: 'var(--text-muted, #94a3b8)', desc: '常规问候与通用协同对话' },
|
||||
system: { label: '系统通知', icon: Info, color: 'var(--text-faint, #64748b)', desc: '系统提示与安全红线拦截' },
|
||||
uncategorized: { label: '其他', icon: Layers, color: 'var(--text-faint, #64748b)', desc: '未归类自由消息' },
|
||||
};
|
||||
|
||||
export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
|
||||
categories,
|
||||
selectedCategory,
|
||||
onSelectCategory,
|
||||
onNavigateToCategory,
|
||||
onToggleCalendar,
|
||||
isCalendarOpen = false,
|
||||
onOpenSearch,
|
||||
@@ -125,11 +127,21 @@ export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
|
||||
const isSelected = selectedCategory === stat.category;
|
||||
const meta = CATEGORY_META[stat.category] || CATEGORY_META.uncategorized;
|
||||
const Icon = meta.icon;
|
||||
const displayLabel = stat.label || meta.label;
|
||||
const tooltip = `${meta.desc} · ${stat.count} 条记录 · 点击定位至最新【${displayLabel}】消息`;
|
||||
|
||||
const handleClick = () => {
|
||||
const next = isSelected ? 'all' : stat.category;
|
||||
onSelectCategory(next);
|
||||
if (onNavigateToCategory && stat.last_message_id) {
|
||||
onNavigateToCategory(next, stat.last_message_id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
key={stat.category}
|
||||
onClick={() => onSelectCategory(isSelected ? 'all' : stat.category)}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -148,10 +160,10 @@ export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
title={`筛选【${stat.label}】分类消息`}
|
||||
title={tooltip}
|
||||
>
|
||||
<Icon size={12} color={isSelected ? '#fff' : meta.color} />
|
||||
<span>{stat.label}</span>
|
||||
<span>{displayLabel}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
|
||||
@@ -226,7 +226,7 @@ export const HistoryCalendar: React.FC<HistoryCalendarProps> = ({
|
||||
}}
|
||||
title={
|
||||
hasMessages
|
||||
? `${dayKey} (共 ${msgCount} 条对话,点击跳转)`
|
||||
? `${dayKey} (共 ${msgCount} 条对话${unreadCount > 0 ? `,含 ${unreadCount} 条未读` : ''},点击跳转)`
|
||||
: `${dayKey} (无记录)`
|
||||
}
|
||||
onMouseEnter={(e) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
parseAnchorHash,
|
||||
formatAnchorHash,
|
||||
navigateToAnchor,
|
||||
resolveTurnAnchorId,
|
||||
} from './anchorNavigator';
|
||||
|
||||
test('parseAnchorHash: 正确解析标准深链 hash', () => {
|
||||
@@ -65,7 +66,7 @@ test('navigateToAnchor: 快路径直接命中 DOM 节点', async () => {
|
||||
|
||||
assert.equal(success, true);
|
||||
assert.equal(scrolled, true);
|
||||
assert.equal(blockOption, 'center');
|
||||
assert.equal(blockOption, 'start');
|
||||
assert.equal(behaviorOption, 'auto'); // 严格无动画
|
||||
assert.equal(mockElement.classList.contains('chat-anchor-pulse'), true);
|
||||
|
||||
@@ -126,3 +127,29 @@ test('navigateToAnchor: 慢路径拉取分片后命中', async () => {
|
||||
|
||||
delete (globalThis as any).document;
|
||||
});
|
||||
|
||||
test('resolveTurnAnchorId: 方案 A 轮次归一化测试', () => {
|
||||
const mockMessages: any[] = [
|
||||
{ id: 'm1', serverId: 101, role: 'user', content: '请问超声刀注册申报要求', category: 'consult' },
|
||||
{ id: 'm2', serverId: 102, role: 'assistant', content: '超声刀软组织设备申报流程如下...', category: 'consult' },
|
||||
{ id: 'm3', serverId: 103, role: 'user', content: '生成立项申报任务', category: 'task' },
|
||||
{ id: 'm4', serverId: 104, role: 'assistant', content: '已生成任务卡片', category: 'task' },
|
||||
{ id: 'm5', serverId: 105, role: 'system', content: '系统维护提示', category: 'system' },
|
||||
];
|
||||
|
||||
// 1. 若目标是 assistant 回复,必须向上溯源至该轮用户提问
|
||||
assert.equal(resolveTurnAnchorId(102, mockMessages), 101, 'assistant msg 102 should resolve to user prompt 101');
|
||||
assert.equal(resolveTurnAnchorId(104, mockMessages), 103, 'assistant msg 104 should resolve to user prompt 103');
|
||||
|
||||
// 2. 若自身就是 user 提问,保持不变
|
||||
assert.equal(resolveTurnAnchorId(101, mockMessages), 101, 'user msg 101 should remain 101');
|
||||
assert.equal(resolveTurnAnchorId(103, mockMessages), 103, 'user msg 103 should remain 103');
|
||||
|
||||
// 3. 孤立系统消息无前序用户提问,回退自身
|
||||
assert.equal(resolveTurnAnchorId(105, mockMessages), 105, 'system msg 105 should fallback to itself');
|
||||
|
||||
// 4. 空消息列表或未匹配,安全返回原 ID
|
||||
assert.equal(resolveTurnAnchorId(999, mockMessages), 999, 'unmatched msg returns raw id');
|
||||
assert.equal(resolveTurnAnchorId(102, []), 102, 'empty messages returns raw id');
|
||||
});
|
||||
|
||||
|
||||
@@ -7,11 +7,61 @@
|
||||
* 4. (P3) 虚拟滚动兼容: 优先通过 VirtualScrollHandle.scrollToMessageId 定位
|
||||
*/
|
||||
|
||||
import { ChatMessage } from '../types/workbench';
|
||||
|
||||
/** P3: 虚拟化滚动器句柄接口 */
|
||||
export interface VirtualScrollHandle {
|
||||
scrollToMessageId: (messageId: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方案 A (交互轮次归一化):
|
||||
* 若目标消息是 assistant 回复,统一向上溯源定位至该轮触发提问的 user 消息 ID。
|
||||
* 配合“起始位置居中”,视口中上方展现用户提问,正下方自然展开助手回答,因果一目了然。
|
||||
*/
|
||||
export function resolveTurnAnchorId(
|
||||
targetMessageId: number,
|
||||
allMessages?: ChatMessage[]
|
||||
): number {
|
||||
if (!allMessages || allMessages.length === 0 || !targetMessageId) {
|
||||
return targetMessageId;
|
||||
}
|
||||
|
||||
const idx = allMessages.findIndex(
|
||||
(m) => (m.serverId ?? 0) === targetMessageId || m.id === String(targetMessageId)
|
||||
);
|
||||
if (idx === -1) return targetMessageId;
|
||||
|
||||
const targetMsg = allMessages[idx];
|
||||
// 方案 A 轮次归一化:仅当自身是 assistant 回复时,才向上溯源到该轮提问的 user 消息
|
||||
if (targetMsg.role !== 'assistant') {
|
||||
return targetMessageId;
|
||||
}
|
||||
|
||||
// 若自身是 assistant,优先通过显式 sourceUserMessageId 寻找提问
|
||||
if (targetMsg.sourceUserMessageId) {
|
||||
const userMsg = allMessages.find((m) => m.id === targetMsg.sourceUserMessageId);
|
||||
if (userMsg && userMsg.serverId) {
|
||||
return userMsg.serverId;
|
||||
}
|
||||
}
|
||||
|
||||
// 沿时间线回溯查找本轮前序最近的一条 user 提问
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (allMessages[i].role === 'user') {
|
||||
const raw = allMessages[i].serverId ?? allMessages[i].id;
|
||||
const numId = typeof raw === 'number' ? raw : parseInt(String(raw), 10);
|
||||
if (typeof numId === 'number' && !isNaN(numId)) {
|
||||
return numId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 孤立系统消息或无前序提问,回退自身
|
||||
return targetMessageId;
|
||||
}
|
||||
|
||||
// 全局注册的虚拟化句柄(MessageStream mount 时注入,unmount 时清除)
|
||||
let _virtualHandle: VirtualScrollHandle | null = null;
|
||||
|
||||
@@ -115,7 +165,15 @@ export async function navigateToAnchor(
|
||||
// 2. 快路径(降级): 目标已在当前 DOM 中(非虚拟化场景)
|
||||
let el = typeof document !== 'undefined' ? document.getElementById(elementId) : null;
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'center', behavior: 'auto' });
|
||||
const container = el.closest?.('[style*="overflow"]') || el.parentElement;
|
||||
if (container && typeof container.getBoundingClientRect === 'function' && typeof el.getBoundingClientRect === 'function') {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const delta = (elRect.top - containerRect.top) - (container.clientHeight / 2);
|
||||
container.scrollTop += delta;
|
||||
} else if (typeof el.scrollIntoView === 'function') {
|
||||
el.scrollIntoView({ block: 'start', behavior: 'auto' });
|
||||
}
|
||||
triggerAnchorHighlight(el, highlightMs);
|
||||
if (typeof window !== 'undefined' && target.sessionId) {
|
||||
window.location.hash = formatAnchorHash(target.sessionId, target.messageId);
|
||||
|
||||
@@ -69,14 +69,14 @@ function roleLabel(role: string): string {
|
||||
|
||||
function categoryLabel(cat?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
milestone: '里程碑',
|
||||
review: '审批审查',
|
||||
task: '任务驱动',
|
||||
file: '文档交付',
|
||||
milestone: '关键节点',
|
||||
review: '审批流转',
|
||||
task: '任务执行',
|
||||
file: '文件收发',
|
||||
consult: '业务咨询',
|
||||
casual: '日常协同',
|
||||
system: '系统消息',
|
||||
uncategorized: '未分类',
|
||||
casual: '日常闲聊',
|
||||
system: '系统通知',
|
||||
uncategorized: '其他',
|
||||
};
|
||||
return cat ? (map[cat] || cat) : '';
|
||||
}
|
||||
|
||||
+182
-23
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* useUnreadMarks — 未读消息标记 Hook (P3)
|
||||
* useUnreadMarks — 双轨制未读消息标记与逐条消费 Hook (P3 & Spec §3.1, §4.3)
|
||||
*
|
||||
* 每 30 秒轮询 GET /api/v1/chat/unread,在协同会话中
|
||||
* 监测同事新消息,并以日期为单位维护未读计数角标。
|
||||
* 1. 周期性轮询 GET /api/v1/chat/unread
|
||||
* 2. 维护按日未读统计、未读总数、未读消息 ID 集合
|
||||
* 3. 提供 consumeNextUnread() 实现「跳最新未读 -> 乐观扣减 -> POST /chat/read -> 全部读完 ✓ 提示」闭环
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
@@ -12,50 +13,104 @@ export interface UnreadMark {
|
||||
date: string;
|
||||
count: number;
|
||||
lastMessageId: number;
|
||||
senders?: string[];
|
||||
}
|
||||
|
||||
interface UnreadApiItem {
|
||||
interface UnreadDayApiItem {
|
||||
date: string;
|
||||
count: number;
|
||||
last_message_id: number;
|
||||
senders?: string[];
|
||||
}
|
||||
|
||||
interface UnreadSummaryResponse {
|
||||
total: number;
|
||||
next_message_id: number;
|
||||
unread_message_ids: number[];
|
||||
items: UnreadDayApiItem[];
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
export function useUnreadMarks(
|
||||
sessionId: string | null,
|
||||
sinceMessageId?: number | null
|
||||
): {
|
||||
export interface UseUnreadMarksReturn {
|
||||
unreadMarks: UnreadMark[];
|
||||
unreadTotal: number;
|
||||
nextMessageId: number | null;
|
||||
unreadMessageIds: number[];
|
||||
isAllReadCompleted: boolean;
|
||||
consumeNextUnread: () => Promise<number | null>;
|
||||
markMessagesRead: (messageIds: number[]) => Promise<void>;
|
||||
clearUnread: (date: string) => void;
|
||||
refresh: () => void;
|
||||
} {
|
||||
}
|
||||
|
||||
export function useUnreadMarks(
|
||||
sessionId: string | null,
|
||||
userId?: number,
|
||||
sinceMessageId?: number | null
|
||||
): UseUnreadMarksReturn {
|
||||
const [unreadMarks, setUnreadMarks] = useState<UnreadMark[]>([]);
|
||||
const [unreadTotal, setUnreadTotal] = useState<number>(0);
|
||||
const [nextMessageId, setNextMessageId] = useState<number | null>(null);
|
||||
const [unreadMessageIds, setUnreadMessageIds] = useState<number[]>([]);
|
||||
const [isAllReadCompleted, setIsAllReadCompleted] = useState<boolean>(false);
|
||||
|
||||
const sinceIdRef = useRef(sinceMessageId);
|
||||
useEffect(() => { sinceIdRef.current = sinceMessageId; }, [sinceMessageId]);
|
||||
useEffect(() => {
|
||||
sinceIdRef.current = sinceMessageId;
|
||||
}, [sinceMessageId]);
|
||||
|
||||
const fetchUnread = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
if (!sessionId) {
|
||||
setUnreadMarks([]);
|
||||
setUnreadTotal(0);
|
||||
setNextMessageId(null);
|
||||
setUnreadMessageIds([]);
|
||||
return;
|
||||
}
|
||||
const since = sinceIdRef.current;
|
||||
try {
|
||||
const params = new URLSearchParams({ session_id: sessionId });
|
||||
if (userId) params.set('user_id', String(userId));
|
||||
if (since && since > 0) params.set('since_message_id', String(since));
|
||||
|
||||
const res = await fetch(`/api/v1/chat/unread?${params.toString()}`);
|
||||
if (!res.ok) return;
|
||||
const data: UnreadApiItem[] = await res.json();
|
||||
setUnreadMarks(
|
||||
data.map((item) => ({
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// 兼容老格式 (Array) 与新格式 (UnreadSummaryResponse)
|
||||
if (Array.isArray(data)) {
|
||||
const items: UnreadMark[] = data.map((item: UnreadDayApiItem) => ({
|
||||
sessionId: sessionId,
|
||||
date: item.date,
|
||||
count: item.count,
|
||||
lastMessageId: item.last_message_id,
|
||||
}))
|
||||
);
|
||||
senders: item.senders,
|
||||
}));
|
||||
setUnreadMarks(items);
|
||||
const total = items.reduce((sum, item) => sum + item.count, 0);
|
||||
setUnreadTotal(total);
|
||||
setNextMessageId(items.length > 0 ? items[0].lastMessageId : null);
|
||||
setUnreadMessageIds(items.map((i) => i.lastMessageId));
|
||||
} else {
|
||||
const summary = data as UnreadSummaryResponse;
|
||||
const items: UnreadMark[] = (summary.items || []).map((item) => ({
|
||||
sessionId: sessionId,
|
||||
date: item.date,
|
||||
count: item.count,
|
||||
lastMessageId: item.last_message_id,
|
||||
senders: item.senders,
|
||||
}));
|
||||
setUnreadMarks(items);
|
||||
setUnreadTotal(summary.total || 0);
|
||||
setNextMessageId(summary.next_message_id > 0 ? summary.next_message_id : null);
|
||||
setUnreadMessageIds(summary.unread_message_ids || []);
|
||||
}
|
||||
} catch {
|
||||
// 网络错误静默处理,不影响主流程
|
||||
// 网络错误静默处理,不中断协同
|
||||
}
|
||||
}, [sessionId]);
|
||||
}, [sessionId, userId]);
|
||||
|
||||
// 定时轮询
|
||||
useEffect(() => {
|
||||
@@ -72,11 +127,115 @@ export function useUnreadMarks(
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, [fetchUnread]);
|
||||
|
||||
const clearUnread = useCallback((date: string) => {
|
||||
setUnreadMarks((prev) => prev.filter((m) => m.date !== date));
|
||||
}, []);
|
||||
// 逐条消费下一条未读消息
|
||||
const consumeNextUnread = useCallback(async (): Promise<number | null> => {
|
||||
if (!sessionId) return null;
|
||||
|
||||
const unreadTotal = unreadMarks.reduce((s, m) => s + m.count, 0);
|
||||
let targetId = nextMessageId;
|
||||
if (!targetId && unreadMessageIds.length > 0) {
|
||||
targetId = unreadMessageIds[0];
|
||||
}
|
||||
if (!targetId) return null;
|
||||
|
||||
return { unreadMarks, unreadTotal, clearUnread, refresh: fetchUnread };
|
||||
// 1. 乐观更新未读集合
|
||||
const remainingIds = unreadMessageIds.filter((id) => id !== targetId);
|
||||
setUnreadMessageIds(remainingIds);
|
||||
setNextMessageId(remainingIds.length > 0 ? remainingIds[0] : null);
|
||||
|
||||
const newTotal = Math.max(0, unreadTotal - 1);
|
||||
setUnreadTotal(newTotal);
|
||||
|
||||
// 乐观调整日桶未读数
|
||||
setUnreadMarks((prev) => {
|
||||
let decremented = false;
|
||||
return prev
|
||||
.map((mark) => {
|
||||
if (!decremented && mark.count > 0) {
|
||||
decremented = true;
|
||||
return { ...mark, count: mark.count - 1 };
|
||||
}
|
||||
return mark;
|
||||
})
|
||||
.filter((mark) => mark.count > 0);
|
||||
});
|
||||
|
||||
// 读完全部后触发 800ms 完成态
|
||||
if (newTotal === 0) {
|
||||
setIsAllReadCompleted(true);
|
||||
setTimeout(() => {
|
||||
setIsAllReadCompleted(false);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// 2. 异步上报后端已读位点
|
||||
try {
|
||||
await fetch('/api/v1/chat/read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
user_id: userId || 0,
|
||||
read_message_ids: [targetId],
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// 错误时不强行重滚,下次拉取自愈
|
||||
}
|
||||
|
||||
return targetId;
|
||||
}, [sessionId, userId, nextMessageId, unreadMessageIds, unreadTotal]);
|
||||
|
||||
// 批量标记已读 (如滚动进入视口阅读或一键已读)
|
||||
const markMessagesRead = useCallback(
|
||||
async (messageIds: number[]) => {
|
||||
if (!sessionId || messageIds.length === 0) return;
|
||||
|
||||
const idsToRead = new Set(messageIds);
|
||||
const remainingIds = unreadMessageIds.filter((id) => !idsToRead.has(id));
|
||||
setUnreadMessageIds(remainingIds);
|
||||
setNextMessageId(remainingIds.length > 0 ? remainingIds[0] : null);
|
||||
|
||||
const newTotal = remainingIds.length;
|
||||
setUnreadTotal(newTotal);
|
||||
|
||||
if (newTotal === 0 && unreadTotal > 0) {
|
||||
setIsAllReadCompleted(true);
|
||||
setTimeout(() => setIsAllReadCompleted(false), 800);
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch('/api/v1/chat/read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
user_id: userId || 0,
|
||||
read_message_ids: messageIds,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// 静默
|
||||
}
|
||||
},
|
||||
[sessionId, userId, unreadMessageIds, unreadTotal]
|
||||
);
|
||||
|
||||
const clearUnread = useCallback(
|
||||
(date: string) => {
|
||||
setUnreadMarks((prev) => prev.filter((m) => m.date !== date));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
unreadMarks,
|
||||
unreadTotal,
|
||||
nextMessageId,
|
||||
unreadMessageIds,
|
||||
isAllReadCompleted,
|
||||
consumeNextUnread,
|
||||
markMessagesRead,
|
||||
clearUnread,
|
||||
refresh: fetchUnread,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,10 +82,20 @@ export function useVirtualMessages({
|
||||
(m) => (m.serverId ?? 0) === messageId
|
||||
);
|
||||
if (idx === -1) return;
|
||||
virtualizer.scrollToIndex(idx, { align: 'center', behavior: 'auto' });
|
||||
const container = containerRef.current;
|
||||
const viewportHeight = container?.clientHeight || 600;
|
||||
const offsetInfo = virtualizer.getOffsetForIndex(idx, 'start');
|
||||
if (offsetInfo) {
|
||||
const [startOffset] = offsetInfo;
|
||||
// 让目标消息起始位置位于中间栏垂直居中偏上位置,防止长文本头部被截断在视口外
|
||||
const targetOffset = Math.max(0, startOffset - viewportHeight / 2);
|
||||
virtualizer.scrollToOffset(targetOffset, { align: 'start', behavior: 'auto' });
|
||||
} else {
|
||||
virtualizer.scrollToIndex(idx, { align: 'start', behavior: 'auto' });
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[flatMessages, virtualizer]
|
||||
[flatMessages, virtualizer, containerRef]
|
||||
),
|
||||
|
||||
isAtBottom: useCallback(() => {
|
||||
|
||||
@@ -125,3 +125,15 @@ input, textarea {
|
||||
transform: scale(1.005);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Dock 锚点坞未读提醒铃铛微动画 */
|
||||
@keyframes dock-bell-pulse {
|
||||
0% { transform: rotate(0deg); }
|
||||
10% { transform: rotate(14deg); }
|
||||
20% { transform: rotate(-12deg); }
|
||||
30% { transform: rotate(10deg); }
|
||||
40% { transform: rotate(-8deg); }
|
||||
50% { transform: rotate(4deg); }
|
||||
60% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user