feat(chat): 完成分类锚点坞(CategoryDock)与双轨制未读重构 (含方案A轮次归一化与起始位置居中)

This commit is contained in:
WeiCHEN
2026-09-13 02:50:20 +00:00
committed by weichen
parent aa19926819
commit 7d293833bf
28 changed files with 2356 additions and 183 deletions
+15
View File
@@ -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);
@@ -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) |
+47
View File
@@ -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)
}
}
}
+17 -8
View File
@@ -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
View File
@@ -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 "其他"
}
}
+129
View File
@@ -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)
}
}
}
+387
View File
@@ -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
}
+138
View File
@@ -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)
}
}
+1 -1
View File
@@ -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])
}
}
+2
View File
@@ -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,
+4
View File
@@ -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)
}
})
+70 -65
View File
@@ -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,
})
}
+1
View File
@@ -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
View File
@@ -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');
});
+479
View File
@@ -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>
);
};
+6 -1
View File
@@ -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) => {
+28 -1
View File
@@ -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');
});
+59 -1
View File
@@ -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);
+7 -7
View File
@@ -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
View File
@@ -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,
};
}
+12 -2
View File
@@ -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(() => {
+12
View File
@@ -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); }
}