diff --git a/deploy/postgres/context_init.sql b/deploy/postgres/context_init.sql index 59a37b11..f994e94c 100644 --- a/deploy/postgres/context_init.sql +++ b/deploy/postgres/context_init.sql @@ -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); diff --git a/deploy/postgres/migrations/2026-09-11-chat-read-positions.sql b/deploy/postgres/migrations/2026-09-11-chat-read-positions.sql new file mode 100644 index 00000000..e810c6bf --- /dev/null +++ b/deploy/postgres/migrations/2026-09-11-chat-read-positions.sql @@ -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); diff --git a/docs/superpowers/specs/2026-09-10-category-dock-anchors-and-unread-redesign-design.md b/docs/superpowers/specs/2026-09-10-category-dock-anchors-and-unread-redesign-design.md new file mode 100644 index 00000000..04e1fc51 --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-category-dock-anchors-and-unread-redesign-design.md @@ -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) | + + diff --git a/internal/chat/category_mapping_test.go b/internal/chat/category_mapping_test.go new file mode 100644 index 00000000..4f740f24 --- /dev/null +++ b/internal/chat/category_mapping_test.go @@ -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) + } + } +} diff --git a/internal/chat/session.go b/internal/chat/session.go index de6856be..b3aed2e2 100644 --- a/internal/chat/session.go +++ b/internal/chat/session.go @@ -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() diff --git a/internal/ctxmgr/category.go b/internal/ctxmgr/category.go index 0b696eba..3b08646d 100644 --- a/internal/ctxmgr/category.go +++ b/internal/ctxmgr/category.go @@ -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 "其他" } } diff --git a/internal/ctxmgr/category_test.go b/internal/ctxmgr/category_test.go new file mode 100644 index 00000000..2b6b09bd --- /dev/null +++ b/internal/ctxmgr/category_test.go @@ -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) + } + } +} diff --git a/internal/ctxmgr/chat_unread.go b/internal/ctxmgr/chat_unread.go new file mode 100644 index 00000000..b91ed271 --- /dev/null +++ b/internal/ctxmgr/chat_unread.go @@ -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 +} diff --git a/internal/ctxmgr/chat_unread_test.go b/internal/ctxmgr/chat_unread_test.go new file mode 100644 index 00000000..a17a7009 --- /dev/null +++ b/internal/ctxmgr/chat_unread_test.go @@ -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) + } +} diff --git a/internal/ctxmgr/chat_window_test.go b/internal/ctxmgr/chat_window_test.go index 099bc4a2..1bd9d7b9 100644 --- a/internal/ctxmgr/chat_window_test.go +++ b/internal/ctxmgr/chat_window_test.go @@ -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]) } } diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index d2a6859f..561c1590 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -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, diff --git a/internal/intent/types.go b/internal/intent/types.go index c67a661c..c95044e4 100644 --- a/internal/intent/types.go +++ b/internal/intent/types.go @@ -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" ) diff --git a/internal/server/chat_unread_and_export_api_test.go b/internal/server/chat_unread_and_export_api_test.go index 9bfd0cb6..d8eee063 100644 --- a/internal/server/chat_unread_and_export_api_test.go +++ b/internal/server/chat_unread_and_export_api_test.go @@ -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) } }) diff --git a/internal/server/chat_unread_api.go b/internal/server/chat_unread_api.go index 01180e31..b0e1a3bc 100644 --- a/internal/server/chat_unread_api.go +++ b/internal/server/chat_unread_api.go @@ -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, + }) } diff --git a/internal/server/server.go b/internal/server/server.go index 7cd7e78d..0f3e2ee8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/web/src/App.tsx b/web/src/App.tsx index b957e226..262d5fb7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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([]); // 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} /> )} - { - navigateToAnchor( - { sessionId: activeSessionId, messageId: targetAnchor }, - { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } - ); - }} - selectedCategory={chatWindow.selectedCategory} - /> +
+ { + navigateToAnchor( + { sessionId: activeSessionId, messageId: targetAnchor }, + { sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor } + ); + }} + selectedCategory={chatWindow.selectedCategory} + unreadMessageIds={unreadMessageIds} + /> + { + 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} + /> +
{pendingProposal && (
{ + 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'); +}); diff --git a/web/src/components/chat/CategoryDock.tsx b/web/src/components/chat/CategoryDock.tsx new file mode 100644 index 00000000..00ce2b20 --- /dev/null +++ b/web/src/components/chat/CategoryDock.tsx @@ -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 = ({ + categories, + activeCategory = 'all', + messages, + unreadTotal, + isAllReadCompleted = false, + onConsumeNextUnread, + onNavigateToCategory, + onSelectCategory, +}) => { + const [hoverIndex, setHoverIndex] = useState(null); + const [cycleIndexMap, setCycleIndexMap] = useState>({}); + const dockRef = useRef(null); + + // 快速映射后端分类统计 + const statMap = useMemo(() => { + const map = new Map(); + for (const c of categories) { + map.set(c.category, c); + } + return map; + }, [categories]); + + // 提取各分类的所有已知消息 ID(方案 A: 统一归一化为提问轮次锚点,按时间/ID 升序排列) + const messageIdsByCategory = useMemo(() => { + const map: Record = {}; + 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(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 ( +
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) */} +
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 ? ( + + ) : ( + 0 ? '#f87171' : 'var(--text-muted, #94a3b8)'} + style={{ + animation: unreadTotal > 0 ? 'dock-bell-pulse 2s infinite' : 'none', + }} + /> + )} + + {/* 红色未读角标 */} + {unreadTotal > 0 && ( + + {unreadTotal > 99 ? '99+' : unreadTotal} + + )} + + {/* 悬停 Mini-Index 气泡 */} + {hoverIndex === 0 && ( +
+
+ + {isAllReadCompleted + ? '全部读完 ✓' + : unreadTotal > 0 + ? `未读消息 (${unreadTotal} 条)` + : '无新未读'} + +
+
+ {unreadTotal > 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 ( +
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', + }} + > + + + {/* 数量角标微标 */} + {count > 0 && ( + + {count} + + )} + + {/* 悬停 Mini-Index 气泡 */} + {hoverIndex === itemIndex && ( +
+
+ + + {displayLabel} + + + {count} 条 + +
+
+ {cfg.desc} +
+ {hasMessages && ( +
+ {targetIds.length > 1 + ? isActive && currentIndex !== undefined + ? `当前第 ${targetIds.length - currentIndex}/${targetIds.length} 条 · 点击跳上一条` + : `点击依次查看该类消息 (共 ${targetIds.length} 条)` + : `点击直达最新一条 (#${targetIds[0]})`} +
+ )} +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index 569a9060..8cd75997 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -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 = ({ onQuote, onNavigateToAnchor, isCategoryDimmed = false, + isUnread = false, }) => { const isUser = message.role === 'user'; const status = message.status; @@ -76,7 +79,9 @@ export const MessageBubble: React.FC = ({ 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', }} >
void; onNavigateToAnchor?: (messageId: number) => void; selectedCategory?: MessageCategory | 'all'; + /** P3 / Step 5: 未读消息 ID 集合 */ + unreadMessageIds?: number[]; } export const MessageStream: React.FC = ({ @@ -37,11 +39,14 @@ export const MessageStream: React.FC = ({ onQuote, onNavigateToAnchor, selectedCategory = 'all', + unreadMessageIds = [], }) => { const containerRef = useRef(null); const isInitialMountRef = useRef(true); const prevMsgCountRef = useRef(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 = ({ isCategoryDimmed={ selectedCategory !== 'all' && msg.category !== selectedCategory } + isUnread={msg.serverId ? unreadSet.has(msg.serverId) : false} />
); diff --git a/web/src/components/history/CategoryFilterBar.tsx b/web/src/components/history/CategoryFilterBar.tsx index 5b324105..9436e786 100644 --- a/web/src/components/history/CategoryFilterBar.tsx +++ b/web/src/components/history/CategoryFilterBar.tsx @@ -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 = ({ categories, selectedCategory, onSelectCategory, + onNavigateToCategory, onToggleCalendar, isCalendarOpen = false, onOpenSearch, @@ -125,11 +127,21 @@ export const CategoryFilterBar: React.FC = ({ 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 (