diff --git a/deploy/postgres/context_init.sql b/deploy/postgres/context_init.sql index 6a38e1b1..4c7f594c 100644 --- a/deploy/postgres/context_init.sql +++ b/deploy/postgres/context_init.sql @@ -109,10 +109,15 @@ CREATE TABLE IF NOT EXISTS chat_messages ( intent TEXT NOT NULL DEFAULT '', branch TEXT NOT NULL DEFAULT '', metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + day_key DATE, + category TEXT NOT NULL DEFAULT 'uncategorized', + deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_time ON chat_messages(session_id, created_at ASC); +CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_day_id ON chat_messages(session_id, day_key DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_category ON chat_messages(session_id, category, id DESC); -- ----------------------------------------------------------------------------- -- 5. CAS 物理文件寻址与引用去重表 (Storage Blobs & File References) diff --git a/deploy/postgres/migrations/2026-09-10-chat-messages-day-window.sql b/deploy/postgres/migrations/2026-09-10-chat-messages-day-window.sql new file mode 100644 index 00000000..970e5eaa --- /dev/null +++ b/deploy/postgres/migrations/2026-09-10-chat-messages-day-window.sql @@ -0,0 +1,33 @@ +-- ============================================================================= +-- Migration: 2026-09-10-chat-messages-day-window.sql +-- Description: 为 chat_messages 新增按日分片列 day_key、消息分类 category 与软删除 deleted_at +-- ============================================================================= + +-- 1. 结构变更:新增列 +ALTER TABLE chat_messages + ADD COLUMN IF NOT EXISTS day_key DATE, + ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'uncategorized', + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + +-- 2. 索引优化:窗口与分类快速定位 +CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_day_id + ON chat_messages(session_id, day_key DESC, id DESC); + +CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_category + ON chat_messages(session_id, category, id DESC); + +-- 3. 存量数据幂等回填 +UPDATE chat_messages +SET day_key = (created_at AT TIME ZONE 'Asia/Shanghai')::date +WHERE day_key IS NULL; + +UPDATE chat_messages SET category = CASE + WHEN metadata ? 'milestone' AND (metadata->>'milestone')::boolean = true THEN 'milestone' + WHEN intent IN ('CASUAL_CHAT') THEN 'casual' + WHEN intent IN ('QUERY_PROGRESS','QUERY_REGULATIONS','SUPPLEMENT_INFO','AMBIGUOUS_FALLBACK') THEN 'consult' + WHEN intent IN ('TRIGGER_TASK','ADVANCE_STAGE') OR metadata ? 'task_request' THEN 'task' + WHEN intent LIKE 'DOCGOV%' OR metadata ? 'interactive_card' THEN 'review' + WHEN metadata ? 'attachments' OR metadata ? 'file_op' THEN 'file' + WHEN role = 'system' THEN 'system' + ELSE 'uncategorized' END +WHERE category = 'uncategorized' OR category = ''; diff --git a/docs/superpowers/specs/2026-09-10-chat-history-archive-navigation-and-daily-digest-design.md b/docs/superpowers/specs/2026-09-10-chat-history-archive-navigation-and-daily-digest-design.md new file mode 100644 index 00000000..67459a24 --- /dev/null +++ b/docs/superpowers/specs/2026-09-10-chat-history-archive-navigation-and-daily-digest-design.md @@ -0,0 +1,734 @@ +# 长对话历史时间轴归档、多级导航锚点与每日智能摘要设计规范 + +- **归档路径**: `docs/superpowers/specs/2026-09-10-chat-history-archive-navigation-and-daily-digest-design.md` +- **编制日期**: 2026-09-10 +- **状态**: 待评审 (Draft for Review) +- **关联文档**: + - `docs/superpowers/specs/2026-09-08-semantic-chat-history-and-case-retrieval-design.md`(多轮会话持久化与标杆案例语义检索,本设计在其 `chat_sessions` / `chat_messages` / `context_memory_chunks` 基础上演进) + - `docs/superpowers/specs/2026-09-09-chat-wait-error-retry-ux-design.md`(SSE 气泡状态机与 `sourceUserMessageId`,本设计的消息 ID 稳定性协议与其衔接) + - `docs/superpowers/specs/2026-09-09-dialogue-llm-casual-ambiguous-design.md`(pi-agent + MiniMax 真实 LLM 接入,本设计的每日摘要生成器复用该 Backend) + +--- + +## 一、 背景与业务目标 (Background & Objectives) + +### 1.1 业务背景 + +在医疗器械 CDMO 全流程协同体系中,一个项目从**策划立项 → 研发设计 → 验证测试 → 注册申报 → 结项归档**,周期通常以月甚至年计。项目工作台的对话流是贯穿全程的决策留痕载体:立项沟通、需求摸底、任务执行、文档审核、交付物上传下载全部沉淀在同一会话中,单会话消息量将随项目推进增长到数千乃至上万条、跨越数十至数百个自然日。 + +### 1.2 现状痛点(代码实证根因) + +| # | 根因 | 代码位置 | 用户可感知后果 | +| :--- | :--- | :--- | :--- | +| 1 | 前端将**全部会话消息**整体存入 localStorage,启动时全量 `JSON.parse`,切换会话时全量 `filter` | `web/src/data/storage.ts` `loadMessages()`;`web/src/App.tsx` `messages.filter(...)` | 打开工作台即全量加载,历史越长首屏越慢;localStorage 5MB 上限迟早溢出 | +| 2 | 消息流**全量渲染 DOM(无虚拟化)**,且每次消息变化执行 `scrollIntoView({ behavior: 'smooth' })` 平滑滚动 | `web/src/components/chat/MessageStream.tsx` | 打开旧会话时肉眼可见"从头滚到底"的长滚动过程,体验极差 | +| 3 | 历史查询接口按 `created_at ASC + LIMIT/OFFSET` 正序分页 | `internal/ctxmgr/chat_history.go` `GetChatHistory`;`GET /api/v1/chat/messages` | 前端想看到"最新对话"必须从最旧一页页翻到底 | +| 4 | 缺乏面向用户的**导航设施**:无按日分片、无分类筛选、无锚点、无搜索、无引用互链、无日期跳转、无每日摘要 | — | 用户"记得当时聊过 X,但找不到在哪",只能肉眼滚动翻找 | + +### 1.3 设计目标 + +1. **首屏直达最新**:打开会话只加载最近 7 天(可配置)消息窗口,且**无滚动动画、瞬时落位**在最新消息处; +2. **按日归档分片**:对话历史以自然日为归档与加载单元,向上滚动按日增量加载,滚动位置不跳动; +3. **多级导航**:分类筛选(闲聊/咨询/任务/审核/文件/里程碑/系统)、全文/模糊/语义三模搜索、日历日期跳转、每日智能摘要跳转,四类入口统一收敛到**锚点定位协议**; +4. **消息引用互链**:用户与智能体双方均可引用历史消息,点击引用即锚点定位; +5. **每日智能摘要**:智能体按日生成工作摘要(要点、产出、决策、未决),用户凭摘要跳转到目标日期首条消息; +6. **性能可量化**:以明确的 P95 预算约束实现与验收(见第十章)。 + +### 1.4 非目标 (Out of Scope) + +- 不改变既有 `chat_summaries`(会话级滚动摘要)的 Prompt 压缩职责,本设计的每日摘要是**面向用户导航的展示物**,二者分工协同(见 4.3); +- 不在本期引入前端路由库与虚拟滚动依赖(P3 再评估,见十一章 P3); +- 不做消息编辑与撤销(软删除策略仅作引用兜底预案,见 4.5)。 + + +--- + +## 二、 需求梳理与总体思路 (Requirements & Overall Approach) + +### 2.1 原始需求逐条深化 + +| # | 原始诉求 | 深化后的设计要点 | +| :--- | :--- | :--- | +| R1 | 按日归档;进入只加载最近 7 天;直接在最新位置,无滚动过程 | DB 层 `day_key` 分片列 + 专用窗口查询;前端**倒序请求、正序渲染**、`useLayoutEffect` 中一次性 `scrollTop` 落底(`behavior:'auto'`,全程禁用 smooth);向上触顶按日 prepend 且滚动位置补偿 | +| R2 | 分类(闲聊/咨询/任务/审核/上传导出下载)+ 锚点快速定位 | 消息落库时打 `category` 标签(对齐既有 `intent`/`branch`/`metadata`/`project_activities.activity_type` 词表);存量数据 SQL 回填;锚点 = `chat_messages.id`(天然全局唯一);分类侧栏 + 分类筛选条 | +| R3 | 关键词/模糊/语义搜索 → 锚点定位 | 三模检索:`ILIKE`/`pg_trgm`(关键词+模糊)+ `pgvector`(语义,复用 `context_memory_chunks` 与既有 Embedder 降级体系);结果按日分组带高亮摘录片段 | +| R4 | 对话内引用,点击引用定位锚点 | 双向引用:用户发送时可引用(输入区引用选择器);智能体回复 metadata 自动携带依据引用;渲染 `QuoteCard`,点击走锚点定位协议 | +| R5 | 按日期跳转到当日第一条 | 日历组件(有消息的日期打点)→ 拉取当日窗口 → 定位当日首条消息锚点 | +| R6 | 按日智能体摘要 → 跳转目标日期首条 | 新表 `chat_daily_digests`;LLM(`internal/agent` Backend)+ 规则降级双轨;定时 + 懒生成 + 当日节流增量三种时机;摘要时间轴侧栏 | + +### 2.2 体系关系:锚点是公共底座 + +``` + ┌─────────────────────────────────────────────┐ + │ 四类导航入口(收敛层) │ + │ 分类筛选 三模搜索 日期跳转 每日摘要 │ + └──────┬─────────┬─────────┬─────────┬────────┘ + └─────────┴────┬────┴─────────┘ + ▼ + ┌──────────────────────────────┐ ┌──────────────┐ + │ 锚点定位协议 navigateToAnchor │◄────│ 引用 QuoteCard │ + │ (窗口未含目标 → 拉日分片 → 插入 │ └──────────────┘ + │ → 瞬时定位 → 高亮脉冲) │ + └──────────────┬───────────────┘ + ▼ + ┌──────────────────────────────┐ + │ 按日消息窗口 (7 天起步, 触顶 │ + │ 向上按日加载, 滚动位置补偿) │ + └──────────────────────────────┘ +``` + +**核心抽象**:R2/R3/R5/R6 四个入口与 R4 引用点击,全部收敛到唯一的 `navigateToAnchor(messageId)` 前端服务。"目标消息不在当前已加载窗口"的拉取-插入-定位逻辑**只实现一份**,任何新导航入口零成本复用。 + +### 2.3 补充设计建议(编制者增补,已纳入本设计) + +| # | 建议 | 动机 | +| :--- | :--- | :--- | +| A1 | **首屏落位禁用一切滚动动画** | 现状 `smooth` 滚动是"滚动过程"体验差的直接原因;恢复/落位一律 `auto`,仅新消息到达且用户已在底部时才平滑跟随 | +| A2 | **消息 ID 稳定性协议** | 现状前端消息 id 为 `'sys-rej-' + Date.now()` 之类的临时字符串,无法充当锚点;规定:锚点一律使用**服务端数字 id**,SSE 流式期间用临时 id,`done` 事件回传真实 id 后前端原地替换(衔接既有 `sourceUserMessageId` 机制) | +| A3 | **里程碑节点导航** | 复用既有 `project_activities.is_milestone`(阶段推进、审批通过、报价确认等不衰减事件),在时间轴侧栏提供"关键节点"入口——长周期项目里用户最想找的往往就是"当时拍板的那个节点",比分类筛选更精准 | +| A4 | **localStorage 窗口化迁移** | 前端不再全量持久化消息;仅缓存当前会话窗口(LRU 3 会话)与输入草稿;历史 SSOT 归数据库(三套环境已有独立 DB,天然支持) | +| A5 | **每日摘要与 `chat_summaries` 分工协同** | `chat_summaries`:会话级、机器上下文压缩(喂 Prompt);`chat_daily_digests`:按日、面向用户导航展示 + 可选注入跨日记忆;摘要向量化沉淀进 `context_memory_chunks`(新 domain `daily_digest`),打通既有语义记忆体系 | +| A6 | **聊天记录按日/按分类导出** | 复用前端已有 `docx` / `xlsx` 依赖,导出带锚点编号的日记录/分类记录,满足医疗器械决策追溯与审计合规诉求 | +| A7 | **搜索 RBAC 权限边界** | 与三级交付物隐私设计一致:私聊内容仅本人可搜;项目共享会话按角色过滤,搜索在服务端过滤而非前端过滤 | +| A8 | **性能预算量化验收** | 所有体验诉求转成 P95 指标(第十章),压测种子数据(90 天 / 12000 条)进入测试方案 | + +--- + +## 三、 整体架构与数据流图 (Architecture & Data Flow) + +``` +┌────────────────────────────────────────────────────────────────────────────┐ +│ Web 工作台 (React 19 + TS + Vite) │ +│ ┌────────────────┐ ┌───────────────────────────┐ ┌──────────────────┐ │ +│ │ HistoryTimeline │ │ MessageStream (按日窗口) │ │ InputArea │ │ +│ │ Rail (时间轴侧栏)│ │ DayDivider + 触顶按日加载 │ │ + 引用选择器 │ │ +│ │ 日历/摘要/分类/ │ │ MessageBubble(QuoteCard) │ │ │ │ +│ │ 搜索/里程碑 │ └────────────▲──────────────┘ └────────┬─────────┘ │ +│ └───────┬────────┘ │ navigateToAnchor() │ SSE │ +│ │ └───────────────────────────┘ │ +│ │ useChatWindow 状态机 (窗口/锚点/草稿, localStorage 窗口化) │ +└──────────┼────────────────────────────────────────────────────────────────┼──┘ + │ REST │ SSE + ▼ ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ Go Server (internal/server) │ +│ GET /chat/messages/window GET /chat/messages/day │ +│ GET /chat/dates GET /chat/categories │ +│ GET /chat/search GET /chat/digests POST /chat/digests/generate│ +│ GET /chat/messages (兼容保留) POST /chat/stream (metadata 扩展) │ +└──────┬──────────────────────┬───────────────────────────┬─────────────────┘ + ▼ ▼ ▼ +┌───────────────┐ ┌─────────────────────┐ ┌────────────────────────────┐ +│ ContextManager │ │ ChatSearchService │ │ DailyDigestScheduler / │ +│ 窗口/日/分类/ │ │ keyword|fuzzy| │ │ DailyDigestGenerator │ +│ 日期查询 + 打标 │ │ semantic|hybrid │ │ LLM(agent.Backend)+规则降级 │ +└──────┬────────┘ └──────────┬──────────┘ └─────────────┬──────────────┘ + │ │ │ 异步索引 + ▼ ▼ ▼ +┌────────────────────────────────────────────────────────────────────────────┐ +│ PostgreSQL 16 + pgvector + pg_trgm │ +│ chat_messages(+day_key,+category) chat_daily_digests(新) │ +│ context_memory_chunks(domain 扩展: chat_message / daily_digest) │ +│ project_activities(is_milestone 里程碑导航源) │ +└────────────────────────────────────────────────────────────────────────────┘ +``` + +### 数据流关键节点 + +1. **写入打标**:`RecordUserMessage` 落库时由应用层计算 `day_key`(统一 `CHAT_TZ`,默认 `Asia/Shanghai`)并依据 `intent`/`branch`/`metadata` 推导 `category`; +2. **首屏窗口**:前端打开会话 → `GET /chat/messages/window?days=7`(服务端按 `day_key DESC` 取日集合、每日内取最新 `MAX_MSG_PER_DAY` 条后正序返回)→ 前端渲染后 `scrollTop` 一次性落底; +3. **触顶加载**:滚动接近顶部 → `window?before_day={oldest}` → prepend 前按 `scrollHeight` 差值补偿,视口不跳动; +4. **锚点定位**:任意入口触发 `navigateToAnchor(id)` → 已在窗口内直接定位;否则 `GET /chat/messages/day?date=&anchor_id=&context=N` 拉目标日及上下文 → 插入窗口 → `scrollIntoView({block:'center', behavior:'auto'})` → 高亮脉冲 1.8s; +5. **搜索**:`GET /chat/search` 三模打分 → 按日分组结果(含高亮摘录)→ 点击走 4; +6. **每日摘要**:Scheduler 每日 00:10 (CHAT_TZ) 对前一日生成;或懒生成(首次请求未命中时异步触发,返回占位状态);摘要向量化沉淀 `context_memory_chunks(domain='daily_digest')` 供跨会话语义召回; +7. **里程碑**:时间轴侧栏直接读 `project_activities` 中 `is_milestone = true` 事件,`detail.message_id` 关联消息,点击即锚点定位。 + +--- + +## 四、 数据库模式设计 (Database Schema) + +### 4.1 `chat_messages` 增列与索引(迁移脚本新增于 `deploy/postgres/migrations/`) + +```sql +-- 按日归档分片键:由应用层写入(避免时区歧义与生成列 IMMUTABLE 争议),统一 CHAT_TZ +ALTER TABLE chat_messages + ADD COLUMN IF NOT EXISTS day_key DATE, + ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'uncategorized'; + +-- 窗口/日查询主索引:(会话, 日, 顺序) +CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_day_id + ON chat_messages(session_id, day_key DESC, id DESC); +-- 分类筛选索引 +CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_category + ON chat_messages(session_id, category, id DESC); + +-- 存量回填(幂等,可重复执行) +UPDATE chat_messages +SET day_key = (created_at AT TIME ZONE 'Asia/Shanghai')::date +WHERE day_key IS NULL; + +UPDATE chat_messages SET category = CASE + WHEN intent IN ('CASUAL_CHAT') THEN 'casual' + WHEN intent IN ('QUERY_PROGRESS','QUERY_REGULATIONS','SUPPLEMENT_INFO','AMBIGUOUS_FALLBACK') THEN 'consult' + WHEN intent IN ('TRIGGER_TASK','ADVANCE_STAGE') OR metadata ? 'task_request' THEN 'task' + WHEN intent LIKE 'DOCGOV%' OR metadata ? 'interactive_card' THEN 'review' + WHEN metadata ? 'attachments' OR metadata ? 'file_op' THEN 'file' + WHEN role = 'system' THEN 'system' + ELSE 'uncategorized' END +WHERE category = 'uncategorized' OR category = ''; +``` + +> `day_key` 采用**应用层写入**而非 DB 生成列:`RecordUserMessage` / SSE 会话服务写入时统一计算,与项目既有"显式列 INSERT"习惯一致,规避 `AT TIME ZONE` 在生成列中的可移植性争议;回填脚本仅一次性使用。 + +### 4.2 分类词表(`category`,与既有体系统一) + +| category | 展示名 | 判定来源(写入时按优先级短路) | +| :--- | :--- | :--- | +| `milestone` | 里程碑 | `metadata.milestone = true`(由 `RecordActivity(is_milestone=true)` 联动写入) | +| `file` | 文件操作 | `metadata.attachments`、`metadata.file_op ∈ {upload, export, download}`(对齐 `project_activities.activity_type`) | +| `review` | 审核治理 | DocGov 意图、`metadata.interactive_card`、驳回/批准系统消息 | +| `task` | 任务执行 | `intent ∈ {TRIGGER_TASK, ADVANCE_STAGE}` 或 `metadata.task_request` 存在 | +| `consult` | 咨询 | `intent ∈ {QUERY_PROGRESS, QUERY_REGULATIONS, SUPPLEMENT_INFO, AMBIGUOUS_FALLBACK}` | +| `casual` | 闲聊 | `intent = CASUAL_CHAT` | +| `system` | 系统通知 | `role = system` | +| `uncategorized` | 未分类 | 兜底(前端归入"全部") | + +### 4.3 新表 `chat_daily_digests`(按日智能摘要) + +```sql +CREATE TABLE IF NOT EXISTS chat_daily_digests ( + id BIGSERIAL PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE, + project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL, + day_key DATE NOT NULL, + headline TEXT NOT NULL DEFAULT '', -- 一句话日总结 (≤40 字) + key_points JSONB NOT NULL DEFAULT '[]'::jsonb, -- 工作要点 (string[]) + deliverables JSONB NOT NULL DEFAULT '[]'::jsonb, -- 当日产出 [{doc_id,title,state}] + decisions JSONB NOT NULL DEFAULT '[]'::jsonb, -- 当日拍板决策 (string[]) + open_questions JSONB NOT NULL DEFAULT '[]'::jsonb, -- 未决事项 (string[]) + stage_progress TEXT NOT NULL DEFAULT '', -- 当日阶段推进 (如 V0.1→V0.5) + first_message_id BIGINT, -- 当日首条消息 (跳转锚点) + last_message_id BIGINT, + message_count INT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', -- pending|generating|ready|fallback|failed + source TEXT NOT NULL DEFAULT '', -- llm | rule (对齐 reply_source 习惯) + model TEXT NOT NULL DEFAULT '', + token_usage INT NOT NULL DEFAULT 0, + digest_version INT NOT NULL DEFAULT 1, -- 当日增量再生成时 +1 + generated_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (session_id, day_key) +); +CREATE INDEX IF NOT EXISTS idx_digests_sid_day ON chat_daily_digests(session_id, day_key DESC); +``` + +**与既有 `chat_summaries` 的分工**:`chat_summaries` 保持"会话级滚动压缩、喂 Prompt"职责不变;`chat_daily_digests` 是按日、面向用户导航的结构化展示物。`DailyDigestGenerator` 生成成功后可将 `headline + key_points` 注入 `RestoreSessionContext` 跨日记忆段作为补充(配置 `DIGEST_INTO_PROMPT`,默认关闭)。 + +### 4.4 搜索基础设施 + +```sql +CREATE EXTENSION IF NOT EXISTS pg_trgm; +-- 关键词/模糊检索共用 +CREATE INDEX IF NOT EXISTS idx_chat_messages_content_trgm + ON chat_messages USING GIN (content gin_trgm_ops); +``` + +- **语义检索**复用既有 `context_memory_chunks`(1024 维 + HNSW + `chunk_hash` 幂等防重),新增两个 domain 取值: + - `chat_message`:`reference_id = "msg:{chat_messages.id}"`,选择性向量化——仅对 `role = user` 消息与 `status = completed` 的助手终稿消息异步入库(控制 token 与存储成本); + - `daily_digest`:`reference_id = "digest:{session_id}:{day_key}"`,每日摘要生成后同步沉淀。 +- **中文全文**:`pg_trgm` 按字符 trigram 处理即可覆盖"关键词包含 + 模糊相似"两档;`zhparser`/`pg_jieba` 分词列为后续可选增强,本期不引入。 + +### 4.5 消息软删除预案(引用兜底) + +```sql +ALTER TABLE chat_messages + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; -- 软删除标记, 默认 NULL +``` + +窗口/日/搜索查询一律过滤 `deleted_at IS NULL`;被引用消息软删除后,`QuoteCard` 降级为"仅展示快照摘录 + 已删除"样式,不再尝试锚点定位。 + +--- + +## 五、 核心组件与接口设计 (Core Components & Interfaces) + +### 5.1 Go 后端:`internal/ctxmgr/chat_window.go`(新增) + +```go +// ChatDayBucket 按日消息分片桶 +type ChatDayBucket struct { + DayKey string `json:"day_key"` // "2026-09-10" + Count int `json:"count"` // 当日消息总数(未截断) + LoadedCount int `json:"loaded_count"` // 本次实际返回条数 + Truncated bool `json:"truncated"` // 单日超 MAX_MSG_PER_DAY 截断标记 + FirstMessageID int64 `json:"first_message_id"` + LastMessageID int64 `json:"last_message_id"` + Messages []ChatMessageRecord `json:"messages"` // 正序 +} + +// ChatWindowQuery 按日窗口查询参数 +type ChatWindowQuery struct { + SessionID string + Days int // 默认 7, 上限 31 + BeforeDay string // 触顶加载: 早于此日的下一段 (YYYY-MM-DD), 空则取最新 +} + +// GetChatWindow 获取最近 N 日消息窗口(倒序取日 → 每日取最新 MAX_MSG_PER_DAY 条 → 正序返回) +func (m *ContextManager) GetChatWindow(ctx context.Context, q ChatWindowQuery) (*ChatWindowResult, error) + +// GetChatDayMessages 获取指定日全量消息(分页游标), 支持 anchor_id 上下文补齐 +func (m *ContextManager) GetChatDayMessages(ctx context.Context, sessionID, dayKey string, anchorID int64, beforeID, afterID int64, limit int) (*ChatDayMessagesResult, error) + +// ListChatDates 列出会话有消息的日期及计数(日历打点 + 摘要联动) +func (m *ContextManager) ListChatDates(ctx context.Context, sessionID string) ([]ChatDateStat, error) + +// GetChatCategoryStats 会话内分类统计 + 各类首末锚点样例 +func (m *ContextManager) GetChatCategoryStats(ctx context.Context, sessionID string) ([]ChatCategoryStat, error) + +// DeriveMessageCategory 写入时打标(4.2 词表的 Go 实现, 供 RecordUserMessage 调用) +func DeriveMessageCategory(role, intent, branch string, meta map[string]any) string +``` + +**实现要点**: +- `GetChatWindow` 两段查询:`SELECT DISTINCT day_key ... WHERE session_id=$1 AND day_key < $2 ORDER BY day_key DESC LIMIT $3` → 逐日 `ORDER BY id DESC LIMIT MAX_MSG_PER_DAY(300)` 后内存反转,天然复用 `idx_chat_messages_sid_day_id`; +- 单日截断不丢锚点能力:`truncated=true` 时前端在 `DayDivider` 显示"本日共 N 条,已加载最新 300 条",点击后经 `GetChatDayMessages` 游标翻全量; +- 内存降级模式(`pool == nil`)沿用既有双轨习惯:`m.messages` 全量切片上做等价窗口切片,保证单测零依赖。 + +### 5.2 Go 后端:`internal/ctxmgr/chat_search.go`(新增 ChatSearchService) + +```go +// ChatSearchMode 检索模式 +type ChatSearchMode string // keyword | fuzzy | semantic | hybrid + +// ChatSearchQuery 搜索入参 +type ChatSearchQuery struct { + SessionID string // 会话内搜索(必填) + ProjectID int64 // 可选: 项目级跨会话搜索 + UserID int64 // 调用者(用于 RBAC 私聊过滤) + Query string + Mode ChatSearchMode // 默认 hybrid + Category string // 可选分类过滤 + DateFrom, DateTo string // 可选日期范围 + Limit int // 默认 50, 上限 200 +} + +// ChatSearchHit 搜索命中 +type ChatSearchHit struct { + MessageID int64 `json:"message_id"` // 锚点 + SessionID string `json:"session_id"` + DayKey string `json:"day_key"` + Role string `json:"role"` + Category string `json:"category"` + Excerpt string `json:"excerpt"` // 命中词 ±40 字上下文, 前端高亮 + Score float64 `json:"score"` + MatchedBy string `json:"matched_by"` // keyword|fuzzy|semantic +} + +// SearchChatMessages 统一搜索入口: 服务端 RBAC 过滤 + 三模打分 + 归一化融合 +func (m *ContextManager) SearchChatMessages(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) +``` + +**实现要点**: +- **keyword**:`content ILIKE '%q%'`(GIN trgm 索引加速),得分按命中段数归一; +- **fuzzy**:`similarity(content, q) > 0.30 ORDER BY similarity DESC`(`pg_trgm`); +- **semantic**:`q` 经 `VectorEmbedder` 向量化(生产 bge-m3 / 离线 `DeterministicEmbedder` 降级,复用既有体系)→ `context_memory_chunks WHERE domain='chat_message'` 余弦 TopK,再 JOIN 回 `chat_messages` 取摘录; +- **hybrid**:三路结果按 `min-max` 归一后加权(keyword 0.4 / fuzzy 0.2 / semantic 0.4)去重合并; +- **RBAC**:私聊会话(`project_id IS NULL` 或私有标记)仅 `user_id` 匹配可命中;项目会话按调用者角色过滤(与三级交付物隐私设计对齐)。 + +### 5.3 Go 后端:`internal/ctxmgr/chat_digest.go`(新增 DailyDigestScheduler / Generator) + +```go +// DailyDigestInput 单日摘要生成输入 +type DailyDigestInput struct { + SessionID string + DayKey string + Messages []ChatMessageRecord // 当日全部消息(服务端取) +} + +// DailyDigest 摘要产出(即 chat_daily_digests 行的 Go 映射) +type DailyDigest struct { + SessionID string `json:"session_id"` + DayKey string `json:"day_key"` + Headline string `json:"headline"` + KeyPoints []string `json:"key_points"` + Deliverables []DigestDeliverable `json:"deliverables"` + Decisions []string `json:"decisions"` + OpenQuestions []string `json:"open_questions"` + StageProgress string `json:"stage_progress"` + FirstMessageID int64 `json:"first_message_id"` // 跳转锚点 + LastMessageID int64 `json:"last_message_id"` + MessageCount int `json:"message_count"` + Status string `json:"status"` + Source string `json:"source"` // llm | rule + DigestVersion int `json:"digest_version"` +} + +// GenerateDailyDigest 单日摘要生成: LLM 优先, 规则降级 +// LLM: agent.Backend (pi-agent + MiniMax), 输出强制 JSON Schema; +// 当日消息 > 80 条时 map-reduce: 先按 20 条分段预摘要, 再汇总 +// 规则降级: 复用 summarizer.go 的确定性抽取思路 (要点=用户消息首句, 决策=metadata.milestone, 产出=file_op) +func (m *ContextManager) GenerateDailyDigest(ctx context.Context, sessionID, dayKey string) (*DailyDigest, error) + +// ListDailyDigests 摘要列表查询(时间轴侧栏数据源), status != ready 时可触发懒生成 +func (m *ContextManager) ListDailyDigests(ctx context.Context, sessionID string, from, to string) ([]DailyDigest, error) + +// StartDailyDigestScheduler 后台调度: 每日 00:10 (CHAT_TZ) 扫描活跃会话生成前一日摘要; +// 当日增量: 每 25 条新消息或 30 分钟节流更新一次 status=ready 的当日摘要(digest_version+1) +// 参照 internal/bizdev/service.go 心跳 goroutine + stop channel 生命周期模式 +func (s *Server) StartDailyDigestScheduler(ctx context.Context) +``` + +**生成时机矩阵**: + +| 时机 | 触发方 | 策略 | +| :--- | :--- | :--- | +| 定时 | Scheduler 每日 00:10 (CHAT_TZ) | 对所有"前一日有消息"的会话批量生成(并发 worker 2,队列防重) | +| 懒生成 | `GET /chat/digests` 未命中 | 异步触发(`status=pending→generating`),接口立即返回占位;前端 60s 轮询或 SSE `digest_ready` 事件通知 | +| 当日增量 | Scheduler 节流(25 条/30 分钟) | 重新生成当日摘要,`digest_version+1`,避免频繁全量重算 | +| 手动 | `POST /chat/digests/generate` | 用户/管理员强制重生成指定日 | + +**幂等与降级**:`UNIQUE(session_id, day_key)` + `status=generating` 抢占防并发重复;LLM 失败自动落 `source=rule, status=fallback`,连续失败 3 次标记 `failed` 并告警;摘要成功后异步向量化沉淀(`domain='daily_digest'`)。 + +--- + +## 六、 RESTful API 契约设计 (API Contracts) + +> 全部挂载于 `internal/server/server.go` 既有 `mux.HandleFunc` 模式,前缀 `/api/v1`。既有 `GET /api/v1/chat/messages`(CLI/黑盒脚本在用)**原样保留兼容**,新能力一律新端点。 + +### 6.1 按日窗口查询(首屏 / 触顶加载) + +- **路径**:`GET /api/v1/chat/messages/window` +- **查询参数**:`session_id`(必), `days`(默认 7, 上限 31), `before_day`(可选, YYYY-MM-DD) +- **响应**: + ```json + { + "session_id": "sess_1788844000", + "days": [ + { + "day_key": "2026-09-10", "count": 38, "loaded_count": 38, "truncated": false, + "first_message_id": 1024, "last_message_id": 1089, + "messages": [ { "id": 1024, "role": "user", "content": "...", "intent": "SUPPLEMENT_INFO", + "branch": "EXPECTATION_HIT", "category": "consult", + "metadata": {}, "created_at": "2026-09-10T09:12:00+08:00" } ] + } + ], + "has_more_before": true, + "oldest_loaded_day": "2026-09-03" + } + ``` + +### 6.2 指定日消息 + 锚点上下文(日期跳转 / 锚点补齐 / 单日翻全量) + +- **路径**:`GET /api/v1/chat/messages/day` +- **查询参数**:`session_id`(必), `date`(必, YYYY-MM-DD), `anchor_id`(可选), `before_id`/`after_id`(可选游标), `limit`(默认 100) +- **语义**:无 `anchor_id` 时返回当日**第一条**起正序 `limit` 条(日期跳转定位当日首条);有 `anchor_id` 时返回锚点**前后各 `limit/2` 条**上下文。 + +### 6.3 会话日期列表(日历打点) + +- **路径**:`GET /api/v1/chat/dates?session_id=` +- **响应**:`[ { "day_key": "2026-09-10", "count": 38, "categories": {"task": 12, "review": 5}, "digest_status": "ready" } ]` + +### 6.4 分类统计(分类筛选条) + +- **路径**:`GET /api/v1/chat/categories?session_id=` +- **响应**:`[ { "category": "task", "label": "任务执行", "count": 512, "first_message_id": 88, "last_message_id": 1089 } ]` + +### 6.5 消息搜索(三模) + +- **路径**:`GET /api/v1/chat/search` +- **查询参数**:`session_id`(必), `q`(必), `mode`(keyword|fuzzy|semantic|hybrid, 默认 hybrid), `category`, `date_from`, `date_to`, `limit`(默认 50, 上限 200), `user_id`(调用者, RBAC) +- **响应**:`[ { "message_id": 1030, "day_key": "2026-09-09", "role": "assistant", "category": "task", "excerpt": "...预算200万已确认...", "score": 0.91, "matched_by": "keyword" } ]`(`` 由前端按关键词渲染高亮,服务端只回纯文本摘录 + 命中区间) + +### 6.6 每日摘要列表 / 手动生成 + +- **路径**:`GET /api/v1/chat/digests?session_id=&from=&to=` + - **响应**:`[ { "day_key": "2026-09-09", "headline": "完成注册路径初筛与报价基准确认", "key_points": ["..."], "deliverables": [{"doc_id":"...","title":"注册路径可行性评估报告","state":"approved"}], "decisions": ["确认国内 NMPA + 欧盟 CE 双路径"], "open_questions": ["型检所排期未定"], "stage_progress": "V0.1→V0.5", "first_message_id": 940, "message_count": 52, "status": "ready", "source": "llm", "digest_version": 2 } ]` + - 未命中且 `date < 今天` 时:异步触发懒生成,响应包含 `{"status": "generating"}` 占位行 + `202` 语义由字段表达(HTTP 仍 200,前端轮询)。 +- **路径**:`POST /api/v1/chat/digests/generate` + - **请求**:`{ "session_id": "...", "day_key": "2026-09-09" }` → **响应**:`{ "queued": true }` + +### 6.7 既有端点扩展(引用与打标透传) + +- `POST /api/v1/chat/stream`(SSE)请求体新增可选字段: + ```json + { "session_id": "...", "message": "...", "user_id": 1, "role": "author", + "quote": { "message_id": 1030, "excerpt": "预算200万已确认", "author_name": "张工", "day_key": "2026-09-09" } } + ``` + 服务端将 `quote` 存入该用户消息 `metadata.quote`;助手回复的 `metadata.quotes`(依据引用数组)随 `done` 事件回传。 +- `done` 事件 payload 新增 `message_id`(助手消息落库真实 id)与 `user_message_id`,用于前端锚点 ID 原地替换(见 8.1)。 + +--- + +## 七、 前端架构设计 (Frontend Architecture) + +### 7.1 锚点定位协议 `navigateToAnchor`(唯一公共底座) + +新增 `web/src/engine/anchorNavigator.ts`: + +```typescript +export interface AnchorTarget { + messageId: number; // 服务端数字 id (chat_messages.id) + sessionId: string; + highlightMs?: number; // 高亮脉冲时长, 默认 1800 +} + +export async function navigateToAnchor( + target: AnchorTarget, + ctx: ChatWindowContext // 由 useChatWindow 提供: 已加载日集合、容器 ref、拉日 API +): Promise { + // 1. 快路径: 锚点消息已在当前窗口 DOM 中 + // el = document.getElementById(`chat-anchor-${target.messageId}`) + // el.scrollIntoView({ block: 'center', behavior: 'auto' }) ← 全程禁用 smooth + // 触发高亮脉冲 class, highlightMs 后移除 + // 2. 慢路径: 不在窗口 → 调 GET /chat/messages/day?date=&anchor_id=&context=50 + // 将返回的日分片插入窗口日栈(去重合并, 保持正序), + // 等 nextTick DOM 渲染完成后再执行 1 的定位逻辑 + // 3. 超时兜底: 拉取 > 3000ms 时 toast 提示"正在定位历史消息..." +} +``` + +**DOM 锚点约定**:`MessageBubble` 根元素固定 `id={`chat-anchor-${serverId}`}`;流式临时消息(尚无 serverId)不挂 DOM 锚点。**深链**:定位成功后同步 `location.hash = `#/s/{sessionId}/m/{messageId}``,工作台启动时解析该 hash 自动执行一次 `navigateToAnchor`(团队内分享"结论位置"场景,无需引入路由库)。 + +### 7.2 `useChatWindow` 状态机(新增 `web/src/hooks/useChatWindow.ts`) + +```typescript +type WindowState = + | { phase: 'loading_window' } // 首屏拉 7 日 + | { phase: 'ready' } // 可交互 + | { phase: 'loading_older'; oldestDay: string } // 触顶拉更早日 + | { phase: 'anchoring'; targetId: number }; // 锚点慢路径中 + +interface ChatWindowContext { + dayStack: ChatDayBucket[]; // 正序日分片栈(渲染顺序) + state: WindowState; + loadOlder(): Promise; // 触顶加载(滚动补偿) + jumpToDate(dayKey: string): Promise; // 日期跳转: 替换为该日窗口再定位首条 + navigateToAnchor(target: AnchorTarget): Promise; + appendLiveMessage(msg: ChatMessage): void; // SSE 新消息(仅收尾落 id 后入栈) +} +``` + +**三条硬规则**: + +1. **首屏瞬时落底**:窗口数据渲染完成后,在 `useLayoutEffect` 中执行 `container.scrollTop = container.scrollHeight`(同步、无动画、无闪烁)。原 `MessageStream` 的 `scrollIntoView({behavior:'smooth'})` **删除**,改为:仅当"新消息到达 && 用户距底部 < 80px"时才 `scrollIntoView({behavior:'smooth'})` 跟随; +2. **触顶滚动补偿**:`loadOlder()` 先记录 `prevHeight = container.scrollHeight`,setState prepend 完成后在 `useLayoutEffect` 中 `container.scrollTop += container.scrollHeight - prevHeight`,视口内容零跳动; +3. **窗口容量保护**:`dayStack` 上限 31 日 / 3000 条(超限从最早日**出栈**,锚点慢路径可随时找回),防长浏览会话内存膨胀。 + +### 7.3 `MessageStream` 改造对照 + +| 现状 | 改造后 | +| :--- | :--- | +| `messages.map` 全量渲染 | 按 `dayStack` 渲染:`DayDivider`(日期 + 条数 + truncated 提示)+ 当日消息 | +| 每次变化 `smooth` 滚底 | 仅"新消息 && 用户在底部"时 smooth 跟随;恢复/跳转一律 `auto` | +| 无触顶感知 | `onScroll` 监听 `scrollTop < 120` 触发 `loadOlder`(防抖 300ms,加载中不重入) | +| 气泡无 DOM id | `id={`chat-anchor-${serverId}`}` + 引用按钮(见 8.2) | +| `messages.filter(sessionId)` 全量过滤 | 数据源即当前会话窗口,不再全量 filter | + +### 7.4 `HistoryTimelineRail` 历史导航侧栏(新增 `web/src/components/history/`) + +置于工作台左栏会话列表下方的可展开 Tab(或独立抽屉,与现有 `LeftRail` / `deckDrawerOpen` 交互习惯一致): + +| 子组件 | 职责 | 数据源 | +| :--- | :--- | :--- | +| `DigestTimeline` | 每日摘要卡片流:headline、要点折叠、产出/决策徽标、"跳到当日"按钮(→ `jumpToDate` → 定位 `first_message_id`) | `GET /chat/digests` | +| `CategoryFilterBar` | 分类 chips(含计数):点击后**窗口内过滤高亮**(非裁剪)+ "仅看此分类"模式(消息流置灰非目标类,侧栏列出该类全部锚点,点击逐个跳) | `GET /chat/categories` | +| `HistoryCalendar` | 轻量手写月历(零新依赖,与现有手写 UI 风格一致):有消息日期打点(数量映射点径),点选 → `jumpToDate` | `GET /chat/dates` | +| `ChatSearchPanel` | 搜索框 + 模式切换(综合/关键词/语义)+ 日期/分类过滤;结果按日分组、摘录高亮、点击 → `navigateToAnchor` | `GET /chat/search` | +| `MilestoneList` | 关键节点列表(阶段推进/审批通过/报价确认),点击 → 锚点定位 `detail.message_id` | `project_activities (is_milestone)` 既有接口扩展 | + +### 7.5 前端类型扩展(`web/src/types/workbench.ts`) + +```typescript +export interface ChatMessage { // 既有字段全部保留, 新增: + serverId?: number; // 服务端数字 id = 锚点; SSE 期间为空, done 后回填 + dayKey?: string; // "2026-09-10" + category?: MessageCategory; // milestone|file|review|task|consult|casual|system|uncategorized + quotes?: QuotedMessage[]; // 本条消息携带的引用(用户主动引用 或 助手依据引用) +} + +export interface QuotedMessage { + messageId: number; // 被引用消息锚点(软删除时仍展示 excerpt) + excerpt: string; // 快照摘录(≤80 字, 引用兜底) + authorName: string; + dayKey: string; + deleted?: boolean; +} +``` + +### 7.6 localStorage 存储策略迁移(`web/src/data/storage.ts`) + +| 项 | 迁移前 | 迁移后 | +| :--- | :--- | :--- | +| 消息持久化 | `STORAGE_KEYS.MESSAGES` 全量存所有会话消息 | **废弃**。仅存 `chat.window.{sessionId}`(当前会话窗口快照,LRU 3 会话,供弱网/二次打开秒显骨架)+ `chat.draft.{sessionId}`(输入草稿与待发送引用) | +| 迁移期兼容 | — | 首次启动检测旧 `MESSAGES` 键:调用 `POST /api/v1/chat/messages/import`(P0 提供一次性回填端点,仅 dev/demo 环境启用,生产默认关闭)后清除旧键 | +| 会话/交付物 | localStorage | 维持现状(不在本期范围) | + +--- + +## 八、 消息 ID 稳定性与引用互链协议 (ID Stability & Quoting) + +### 8.1 锚点 ID 稳定性协议(SSE 临时 id → 服务端真实 id) + +``` +用户发送 → 前端生成临时 id "tmp-{uuid}" (仅本次会话生命周期, 不落 localStorage) + → POST /chat/stream (SSE) + → done 事件: { message_id: 1090, user_message_id: 1089, ... } + → 前端将两条消息的 id 原地替换: tmp → serverId (React key 同步更新) + → 此后该消息即具锚点资格 (DOM id 挂载, 可被引用/搜索命中/摘要统计) +``` + +- 服务端:`RecordUserMessage` 已返回影响的 `chat_messages.id`(pgx `RETURNING id` 扩展),SSE `done` 聚合回传(衔接既有 `sourceUserMessageId` 机制); +- 前端:替换期间保持组件状态不重置(按内容指纹匹配替换,避免流式动画闪断); +- 兜底:`done` 丢失真实 id 时(异常流),下一次打开会话经窗口接口自然取回真实 id,临时 id 不持久化故无污染。 + +### 8.2 引用交互(双向) + +**用户引用**(InputArea + 引用选择器): +1. 任意气泡 hover 出现"引用"按钮(仅具 `serverId` 的消息); +2. 点击后进入"引用待选"态:输入区上方展示 `QuoteChip`(可移除),支持连续多选(≤3); +3. 选择器内置两级:最近 50 条 + 搜索选择(复用 `ChatSearchPanel` 结果点击即选入); +4. 发送时 `quote[]` 随 SSE body 上行,服务端写入用户消息 `metadata.quote`。 + +**智能体引用**(回复依据): +- 助手回复涉及历史结论时(如"基于 9 月 8 日您确认的 200 万预算"),协调层在 `metadata.quotes` 中回填依据消息锚点数组(来源:`RestoreSessionContext` 命中的事实消息 / 案例推荐依据); +- `MessageBubble` 渲染 `QuoteCard`:作者 + 日期 + 摘录快照,点击 → `navigateToAnchor`; +- 被引用消息软删除:`deleted=true` 时卡片置灰显示"原消息已删除",仅展示快照。 + +--- + +## 九、 离线降级与异常处理规范 (Error Handling & Fallback) + +| 故障面 | 降级策略 | +| :--- | :--- | +| Embedder 服务不可用(语义搜索) | 复用既有 `DeterministicEmbedder`(1024 维语义哈希投影)离线降级,语义档自动退化为"确定性向量召回",UI 标注"语义检索降级中" | +| LLM 摘要失败 | 自动落规则摘要(`source=rule, status=fallback`):要点=用户消息首句截断、决策=`metadata.milestone`、产出=`file_op`,复用 `summarizer.go` 确定性思路;连续失败 3 次标记 `failed` 并在侧栏展示"摘要暂不可用" | +| 数据库不可用 | 沿用 ContextManager 双轨:内存切片等价窗口化(单测零依赖);摘要/搜索内存版仅支持关键词档;窗口接口返回内存快照 | +| 窗口接口超时(弱网) | 前端骨架屏 + `chat.window.{sessionId}` 本地快照秒显,后台静默刷新替换 | +| 锚点目标消息不存在(已删/权限外) | 接口 404 → toast"该消息已不存在或无权访问";引用卡片走快照兜底 | +| 单日爆量(任务流 >300 条) | 窗口截断最新 300 条 + `truncated` 标记 + DayDivider 提示"本日共 N 条",点击经游标翻全量 | +| 搜索查询为空/过短(<2 字符) | 400 + 前端禁用搜索按钮,提示最小查询长度 | +| localStorage 配额溢出 | 窗口快照写入 try-catch 静默失败(既有 `safeSetItem` 习惯),仅丢快照不丢功能 | + +--- + +## 十、 性能预算与验收指标 (Performance Budget) + +| 指标 | 预算(P95) | 测量口径 | +| :--- | :--- | :--- | +| 会话首屏定位到最新消息 | ≤ 300ms(渲染完成至落底,本地);≤ 800ms(含网络) | `performance.mark` 打点:window 响应到达 → `scrollTop` 落底完成 | +| 触顶加载上一日(1 日分片) | ≤ 500ms | 网络请求发起 → prepend 渲染 + 滚动补偿完成 | +| 锚点跳转(已在窗口) | ≤ 50ms | 点击 → `scrollIntoView` 完成 | +| 锚点跳转(跨日慢路径) | ≤ 800ms | 点击 → 拉日 + 插入 + 定位 + 高亮启动 | +| 搜索(hybrid) | ≤ 1.5s(含 embedding) | 请求发起 → 结果渲染 | +| 每日摘要懒生成 | 后台 ≤ 60s,不阻塞任何 UI | 触发 → `status=ready` | +| 首屏 DOM 气泡数 | ≤ 300 | `MAX_MSG_PER_DAY × 7` 截断保证 | +| `dayStack` 内存 | ≤ 3000 条消息 | 窗口容量保护规则保证 | + +**压测种子**:新增 `scripts/build_long_history_seed.py`(参照既有 `build_neuropulse_demo_seed.py` 风格),生成 1 会话 / 90 天 / 12000 条(含任务流单日 500 条的爆量日),用于上述全部指标的自动化测量。 + +--- + +## 十一、 实施阶段划分 (Phased Implementation) + +> 遵循仓库"按功能层次拆分,过程逐步提交"规范:`feat(db)` → `feat(agent)` → `feat(api)` → `test(...)` → `docs(...)`。 + +### P0 — 止血:首屏直达与按日窗口(最高优先级,先解决"打开慢 + 滚动过程") + +1. `feat(db)`: `day_key`/`category` 增列 + 索引 + 存量回填(4.1);`RecordUserMessage` 写入打标; +2. `feat(agent)`: `GetChatWindow` / `GetChatDayMessages` / `ListChatDates`(内存双轨); +3. `feat(api)`: `GET /chat/messages/window`、`GET /chat/messages/day`、`GET /chat/dates`; +4. 前端:`useChatWindow` + `MessageStream` 窗口化改造 + **删除 smooth 落底** + `DayDivider` + 触顶补偿加载 + localStorage 窗口化迁移; +5. `test`: `chat_window_test.go` / `chat_window_api_test.go` / `useChatWindow.test.ts` + 压测种子脚本。 + **P0 验收**:90 天 12000 条种子下,打开会话 P95 ≤ 300ms 落底且**全程无肉眼可见滚动过程**。 + +### P1 — 导航体系:锚点 / 分类 / 日期跳转 / 引用 + +1. 锚点协议 `anchorNavigator.ts` + DOM id + 深链 hash; +2. `GET /chat/categories` + `CategoryFilterBar` + 分类锚点列表; +3. `HistoryCalendar` + `jumpToDate`(定位当日首条); +4. SSE `done` 回传真实 id(8.1)+ 用户引用(引用选择器/QuoteChip/上行 quote)+ `QuoteCard` 渲染与点击定位。 + +### P2 — 智能层:搜索 / 每日摘要 / 时间轴侧栏 + +1. `pg_trgm` + `chat_message` 选择性向量化 + `GET /chat/search` + `ChatSearchPanel`; +2. `chat_daily_digests` 表 + `DailyDigestGenerator`(LLM + 规则降级)+ `Scheduler`(定时/懒生成/当日节流增量)+ `GET|POST /chat/digests`; +3. `DigestTimeline` + `MilestoneList`(`project_activities` 扩展 `detail.message_id`); +4. 摘要向量化沉淀 `daily_digest` domain(打通语义记忆体系)。 + +### P3 — 增强(按需排期) + +1. 虚拟滚动(`@tanstack/react-virtual`,动态高度 Dynamic mode,需评估与触顶补偿/锚点定位的配合); +2. 聊天记录导出(复用已有 `docx`/`xlsx` 依赖:按日/按分类导出带锚点编号的记录,合规审计场景); +3. 未读提醒(协同会话同事新消息 → 时间轴日期轴未读角标); +4. CLI 锚点支持(`bizdev-cli --goto-msg {id}` 简单定位); +5. 结项项目对话冷归档(`chat_messages` 活跃项目热表 → 归档表,配合项目生命周期状态机)。 + +--- + +## 十二、 质量验收与测试方案 (Test & Quality Gate) + +### 12.1 Go 单元与集成测试(`go test ./...`) + +| 测试文件 | 覆盖 | +| :--- | :--- | +| `internal/ctxmgr/chat_window_test.go` | 窗口 7 日取数、`before_day` 分段、单日截断、日查询锚点上下文、日期列表、内存双轨等价性 | +| `internal/ctxmgr/chat_search_test.go` | keyword/fuzzy/semantic/hybrid 四档、RBAC 私聊过滤、日期与分类过滤、空查询拒绝 | +| `internal/ctxmgr/chat_digest_test.go` | LLM 成功路径(stub Backend)、规则降级路径、幂等(重复生成)、当日增量 `digest_version`、懒生成占位 | +| `internal/ctxmgr/chat_category_test.go` | `DeriveMessageCategory` 全词表短路优先级、`day_key` 时区归属(CHAT_TZ 边界 00:00 前后消息) | +| `internal/server/chat_window_api_test.go` | 6.1–6.6 全端点契约、错误码、SSE `done` 携带 `message_id`/`user_message_id` 与 `quote` 透传 | + +### 12.2 Web 单元测试(`npm test`:`tsx --test` + vitest,遵循现有 test script 排除模式) + +| 测试文件 | 覆盖 | +| :--- | :--- | +| `web/src/hooks/useChatWindow.test.ts` | 状态机流转、触顶滚动补偿数值、窗口容量出栈、首屏落底调用 | +| `web/src/engine/anchorNavigator.test.ts` | 快路径定位、慢路径拉日插入后定位、404 兜底、hash 深链生成与解析 | +| `web/src/components/history/DayDivider.test.ts`(及各子组件) | truncated 提示、分类计数渲染、日历打点、QuoteCard 快照兜底 | + +### 12.3 真实 E2E 与性能验收 + +1. `scripts/run_history_navigation_e2e.py`(参照 `run_real_e2e_blackbox.py` 风格):六大用户故事全链路—— + a. 打开 90 天老会话 → 断言首屏窗口 7 日 + 无滚动动画(前端上报 `performance` 打点); + b. 触顶连续加载至 31 日上限; + c. 分类筛选 → 里程碑锚点跳转; + d. 搜索"预算" → hybrid 命中 → 锚点定位; + e. 引用历史消息发送 → 回复 `QuoteCard` 点击定位; + f. 查看每日摘要 → 跳转目标日首条; +2. 性能脚本断言第十章全部 P95 预算(超预算即测试失败); +3. 测试报告统一归档 `.doc/test-reports/2026-09-10-chat-history-navigation-test-report.md`。 + +--- + +## 十三、 风险与开放问题 (Risks & Open Questions) + +| # | 风险/问题 | 应对策略 | 状态 | +| :--- | :--- | :--- | :--- | +| 1 | 时区与跨日边界(凌晨消息归属、服务器 UTC 与业务日不一致) | `CHAT_TZ` 配置统一(默认 Asia/Shanghai),`day_key` 只由应用层一处计算;边界用例进单测 | 已决策 | +| 2 | 单日消息爆量(长任务 SSE 产生数百条助手消息) | `MAX_MSG_PER_DAY=300` 截断 + 游标翻全量 + 锚点上下文补齐,双层兜底 | 已决策 | +| 3 | 语义搜索成本(全量向量化 token/存储开销) | 选择性向量化(用户消息 + completed 助手终稿 + 每日摘要),失败自动降级确定性向量 | 已决策 | +| 4 | 每日摘要质量(LLM 幻觉、要点失真) | 输出强制 JSON Schema + `source` 标注 + 人工"重新生成"入口;摘要仅导航用途不作为合规依据 | 已决策 | +| 5 | 前端旧 localStorage 数据迁移(demo 数据 vs 真实数据混杂) | `POST /chat/messages/import` 仅 dev/demo 启用;生产默认走后端种子 | 待评审确认环境开关 | +| 6 | 虚拟滚动与锚点定位/触顶补偿的兼容(P3) | 按需专项评估(动态高度测量 + `scrollTo` offset 计算),不阻塞 P0-P2 | 开放 | +| 7 | 多端一致(CLI / Web 锚点语义统一) | P3 提供 CLI `--goto-msg`;锚点 id 语义(服务端数字 id)已两端无关 | 开放 | +| 8 | 结项冷归档对既有查询的影响(P3) | 归档表与热表同构,查询层加 `project.status` 路由;预留 `deleted_at` 之外的 `archived_at` 扩展位 | 开放 | + +--- + +## 附:与既有能力的复用关系总表 + +| 既有能力 | 本设计复用方式 | +| :--- | :--- | +| `chat_messages` / `chat_sessions` 持久化 | 增列演进,不动主键与既有索引 | +| `context_memory_chunks` + pgvector + HNSW | 扩展 domain(`chat_message` / `daily_digest`)承载语义搜索与摘要沉淀 | +| `DeterministicEmbedder` 离线降级 | 语义搜索/摘要 embedding 降级直接复用 | +| `internal/agent` Backend(pi-agent + MiniMax) | 每日摘要 LLM 生成器注入 | +| `summarizer.go` 确定性抽取思路 | 摘要规则降级路径 | +| `project_activities`(activity_type / is_milestone) | 分类词表对齐 + 里程碑导航数据源 | +| `sourceUserMessageId` / SSE 状态机 | 消息 ID 稳定性协议衔接 | +| `docx` / `xlsx` 前端依赖 | P3 聊天记录导出 | +| `internal/bizdev/service.go` 心跳 goroutine 模式 | DigestScheduler 生命周期管理 | + + + + + + diff --git a/internal/ctxmgr/category.go b/internal/ctxmgr/category.go new file mode 100644 index 00000000..1d43ae5b --- /dev/null +++ b/internal/ctxmgr/category.go @@ -0,0 +1,74 @@ +package ctxmgr + +import "strings" + +// Category 常量定义 +const ( + CategoryMilestone = "milestone" + CategoryFile = "file" + CategoryReview = "review" + CategoryTask = "task" + CategoryConsult = "consult" + CategoryCasual = "casual" + CategorySystem = "system" + CategoryUncategorized = "uncategorized" +) + +// DeriveMessageCategory 根据消息元信息与意图推导分类 (按优先级短路) +func DeriveMessageCategory(role, intent, branch string, meta map[string]any) string { + if meta != nil { + // 1. milestone: 里程碑节点 + if mVal, ok := meta["milestone"]; ok { + if b, ok := mVal.(bool); ok && b { + return CategoryMilestone + } + } + // 2. file: 附件上传/导出/下载 + if _, ok := meta["attachments"]; ok { + return CategoryFile + } + if fOp, ok := meta["file_op"].(string); ok { + switch fOp { + case "upload", "export", "download": + return CategoryFile + } + } + // 3. review: 包含交互卡片 (驳回门禁/确认卡片) + if _, ok := meta["interactive_card"]; ok { + return CategoryReview + } + // 4. task: 包含任务启动请求 + if _, ok := meta["task_request"]; ok { + return CategoryTask + } + } + + // 5. review: 文档治理/审查意图 + if strings.HasPrefix(intent, "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": + return CategoryConsult + } + + // 8. casual: 闲聊意图 + if intent == "CASUAL_CHAT" { + return CategoryCasual + } + + // 9. system: 系统角色通知 + if role == "system" { + return CategorySystem + } + + return CategoryUncategorized +} diff --git a/internal/ctxmgr/chat_window.go b/internal/ctxmgr/chat_window.go new file mode 100644 index 00000000..f8477e4e --- /dev/null +++ b/internal/ctxmgr/chat_window.go @@ -0,0 +1,666 @@ +package ctxmgr + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "time" +) + +const ( + DefaultWindowDays = 7 + MaxWindowDays = 31 + MaxMsgPerDay = 300 +) + +// GetChatWindow 获取指定会话的按日分片窗口 (倒序取日集合 → 每日取最新 MAX_MSG_PER_DAY 条 → 正序返回) +func (m *ContextManager) GetChatWindow(ctx context.Context, q ChatWindowQuery) (*ChatWindowResult, error) { + if q.SessionID == "" { + return nil, errors.New("session_id required") + } + if q.Days <= 0 { + q.Days = DefaultWindowDays + } + if q.Days > MaxWindowDays { + q.Days = MaxWindowDays + } + + if m.pool != nil { + return m.getChatWindowPG(ctx, q) + } + + return m.getChatWindowMem(ctx, q) +} + +func (m *ContextManager) getChatWindowPG(ctx context.Context, q ChatWindowQuery) (*ChatWindowResult, error) { + // 1. 查询目标日集合 (DESC 排序取最近 N 日) + var dayRowsQuery string + var dayRowsArgs []any + if q.BeforeDay != "" { + dayRowsQuery = ` + SELECT DISTINCT day_key + FROM chat_messages + WHERE session_id = $1 AND deleted_at IS NULL AND day_key IS NOT NULL AND day_key < $2::date + ORDER BY day_key DESC + LIMIT $3 + ` + dayRowsArgs = []any{q.SessionID, q.BeforeDay, q.Days} + } else { + dayRowsQuery = ` + SELECT DISTINCT day_key + FROM chat_messages + WHERE session_id = $1 AND deleted_at IS NULL AND day_key IS NOT NULL + ORDER BY day_key DESC + LIMIT $2 + ` + dayRowsArgs = []any{q.SessionID, q.Days} + } + + rows, err := m.pool.Query(ctx, dayRowsQuery, dayRowsArgs...) + if err != nil { + return nil, fmt.Errorf("query distinct days failed: %w", err) + } + defer rows.Close() + + var descDays []string + for rows.Next() { + var d time.Time + if err := rows.Scan(&d); err == nil { + descDays = append(descDays, d.Format("2006-01-02")) + } + } + + if len(descDays) == 0 { + return &ChatWindowResult{ + SessionID: q.SessionID, + Days: []ChatDayBucket{}, + HasMoreBefore: false, + OldestLoadedDay: "", + }, nil + } + + oldestDay := descDays[len(descDays)-1] + + // 2. 检查是否有更早日 + var hasMoreBefore bool + err = m.pool.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM chat_messages + WHERE session_id = $1 AND deleted_at IS NULL AND day_key IS NOT NULL AND day_key < $2::date + ) + `, q.SessionID, oldestDay).Scan(&hasMoreBefore) + if err != nil { + hasMoreBefore = false + } + + // 3. 对选中的每一个日拉取统计和最新 MAX_MSG_PER_DAY 条 + buckets := make([]ChatDayBucket, 0, len(descDays)) + for _, dayStr := range descDays { + var totalCount int + var firstID, lastID *int64 + _ = m.pool.QueryRow(ctx, ` + SELECT count(*), MIN(id), MAX(id) + FROM chat_messages + WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL + `, q.SessionID, dayStr).Scan(&totalCount, &firstID, &lastID) + + fID := int64(0) + lID := int64(0) + if firstID != nil { + fID = *firstID + } + if lastID != nil { + lID = *lastID + } + + // 倒序取最多 MaxMsgPerDay 条 + msgRows, err := m.pool.Query(ctx, ` + SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at + FROM chat_messages + WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL + ORDER BY id DESC + LIMIT $3 + `, q.SessionID, dayStr, MaxMsgPerDay) + if err != nil { + return nil, fmt.Errorf("query messages for day %s failed: %w", dayStr, err) + } + + var msgsDesc []ChatMessageRecord + for msgRows.Next() { + var r ChatMessageRecord + var metaBytes []byte + if err := msgRows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil { + if len(metaBytes) > 0 { + _ = json.Unmarshal(metaBytes, &r.Metadata) + } + msgsDesc = append(msgsDesc, r) + } + } + msgRows.Close() + + // 内存反转为正序 ASC + msgsAsc := make([]ChatMessageRecord, len(msgsDesc)) + for i, mRec := range msgsDesc { + msgsAsc[len(msgsDesc)-1-i] = mRec + } + + bucket := ChatDayBucket{ + DayKey: dayStr, + Count: totalCount, + LoadedCount: len(msgsAsc), + Truncated: totalCount > MaxMsgPerDay, + FirstMessageID: fID, + LastMessageID: lID, + Messages: msgsAsc, + } + buckets = append(buckets, bucket) + } + + // 4. 将日集合反转为正序 (最旧自然日排在最前面,供前端自顶向下按自然日渲染) + sort.Slice(buckets, func(i, j int) bool { + return buckets[i].DayKey < buckets[j].DayKey + }) + + return &ChatWindowResult{ + SessionID: q.SessionID, + Days: buckets, + HasMoreBefore: hasMoreBefore, + OldestLoadedDay: oldestDay, + }, nil +} + +func (m *ContextManager) getChatWindowMem(ctx context.Context, q ChatWindowQuery) (*ChatWindowResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + allMsgs, exists := m.messages[q.SessionID] + if !exists || len(allMsgs) == 0 { + return &ChatWindowResult{ + SessionID: q.SessionID, + Days: []ChatDayBucket{}, + HasMoreBefore: false, + OldestLoadedDay: "", + }, nil + } + + // 按 dayKey 分组 + dayMap := make(map[string][]ChatMessageRecord) + for _, msg := range allMsgs { + if msg.DeletedAt != nil { + continue + } + dKey := msg.DayKey + if dKey == "" { + dKey = msg.CreatedAt.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02") + } + dayMap[dKey] = append(dayMap[dKey], msg) + } + + var allDays []string + for k := range dayMap { + allDays = append(allDays, k) + } + sort.Slice(allDays, func(i, j int) bool { + return allDays[i] > allDays[j] // DESC + }) + + var filteredDays []string + for _, d := range allDays { + if q.BeforeDay != "" && d >= q.BeforeDay { + continue + } + filteredDays = append(filteredDays, d) + } + + if len(filteredDays) == 0 { + return &ChatWindowResult{ + SessionID: q.SessionID, + Days: []ChatDayBucket{}, + HasMoreBefore: false, + OldestLoadedDay: "", + }, nil + } + + hasMoreBefore := false + if len(filteredDays) > q.Days { + hasMoreBefore = true + filteredDays = filteredDays[:q.Days] + } + + oldestDay := filteredDays[len(filteredDays)-1] + + buckets := make([]ChatDayBucket, 0, len(filteredDays)) + for _, dayStr := range filteredDays { + dMsgs := dayMap[dayStr] + totalCount := len(dMsgs) + var fID, lID int64 + if totalCount > 0 { + fID = dMsgs[0].ID + lID = dMsgs[totalCount-1].ID + } + + // 取最后 MAX_MSG_PER_DAY 条 + start := 0 + truncated := false + if totalCount > MaxMsgPerDay { + start = totalCount - MaxMsgPerDay + truncated = true + } + selected := dMsgs[start:] + + copied := make([]ChatMessageRecord, len(selected)) + copy(copied, selected) + + buckets = append(buckets, ChatDayBucket{ + DayKey: dayStr, + Count: totalCount, + LoadedCount: len(copied), + Truncated: truncated, + FirstMessageID: fID, + LastMessageID: lID, + Messages: copied, + }) + } + + // 排序为 ASC + sort.Slice(buckets, func(i, j int) bool { + return buckets[i].DayKey < buckets[j].DayKey + }) + + return &ChatWindowResult{ + SessionID: q.SessionID, + Days: buckets, + HasMoreBefore: hasMoreBefore, + OldestLoadedDay: oldestDay, + }, nil +} + +// GetChatDayMessages 获取指定日消息,支持 anchor_id 上下文补齐或基于游标的分页拉取 +func (m *ContextManager) GetChatDayMessages(ctx context.Context, sessionID, dayKey string, anchorID int64, beforeID, afterID int64, limit int) (*ChatDayMessagesResult, error) { + if sessionID == "" || dayKey == "" { + return nil, errors.New("session_id and date required") + } + if limit <= 0 { + limit = 100 + } + if limit > MaxMsgPerDay { + limit = MaxMsgPerDay + } + + if m.pool != nil { + return m.getChatDayMessagesPG(ctx, sessionID, dayKey, anchorID, beforeID, afterID, limit) + } + + return m.getChatDayMessagesMem(ctx, sessionID, dayKey, anchorID, beforeID, afterID, limit) +} + +func (m *ContextManager) getChatDayMessagesPG(ctx context.Context, sessionID, dayKey string, anchorID int64, beforeID, afterID int64, limit int) (*ChatDayMessagesResult, error) { + var totalCount int + _ = m.pool.QueryRow(ctx, ` + SELECT count(*) FROM chat_messages + WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL + `, sessionID, dayKey).Scan(&totalCount) + + var msgs []ChatMessageRecord + var hasMoreBefore, hasMoreAfter bool + + if anchorID > 0 { + // 锚点上下文:前后各取 limit/2 条 + half := limit / 2 + if half < 1 { + half = 1 + } + + // before or equal to anchor + rowsBefore, err := m.pool.Query(ctx, ` + SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at + FROM chat_messages + WHERE session_id = $1 AND day_key = $2::date AND id <= $3 AND deleted_at IS NULL + ORDER BY id DESC + LIMIT $4 + `, sessionID, dayKey, anchorID, half) + if err == nil { + var beforeDesc []ChatMessageRecord + for rowsBefore.Next() { + var r ChatMessageRecord + var metaBytes []byte + if err := rowsBefore.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil { + if len(metaBytes) > 0 { + _ = json.Unmarshal(metaBytes, &r.Metadata) + } + beforeDesc = append(beforeDesc, r) + } + } + rowsBefore.Close() + for i := len(beforeDesc) - 1; i >= 0; i-- { + msgs = append(msgs, beforeDesc[i]) + } + } + + // after anchor + rowsAfter, err := m.pool.Query(ctx, ` + SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at + FROM chat_messages + WHERE session_id = $1 AND day_key = $2::date AND id > $3 AND deleted_at IS NULL + ORDER BY id ASC + LIMIT $4 + `, sessionID, dayKey, anchorID, half) + if err == nil { + for rowsAfter.Next() { + var r ChatMessageRecord + var metaBytes []byte + if err := rowsAfter.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil { + if len(metaBytes) > 0 { + _ = json.Unmarshal(metaBytes, &r.Metadata) + } + msgs = append(msgs, r) + } + } + rowsAfter.Close() + } + } else if beforeID > 0 { + rows, err := m.pool.Query(ctx, ` + SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at + FROM chat_messages + WHERE session_id = $1 AND day_key = $2::date AND id < $3 AND deleted_at IS NULL + ORDER BY id DESC + LIMIT $4 + `, sessionID, dayKey, beforeID, limit) + if err == nil { + var desc []ChatMessageRecord + for rows.Next() { + var r ChatMessageRecord + var metaBytes []byte + if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil { + if len(metaBytes) > 0 { + _ = json.Unmarshal(metaBytes, &r.Metadata) + } + desc = append(desc, r) + } + } + rows.Close() + for i := len(desc) - 1; i >= 0; i-- { + msgs = append(msgs, desc[i]) + } + } + } else if afterID > 0 { + rows, err := m.pool.Query(ctx, ` + SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at + FROM chat_messages + WHERE session_id = $1 AND day_key = $2::date AND id > $3 AND deleted_at IS NULL + ORDER BY id ASC + LIMIT $4 + `, sessionID, dayKey, afterID, limit) + if err == nil { + for rows.Next() { + var r ChatMessageRecord + var metaBytes []byte + if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil { + if len(metaBytes) > 0 { + _ = json.Unmarshal(metaBytes, &r.Metadata) + } + msgs = append(msgs, r) + } + } + rows.Close() + } + } else { + // 默认从当日第一条起正序 limit 条 + rows, err := m.pool.Query(ctx, ` + SELECT id, session_id, COALESCE(user_id, 0), role, content, intent, branch, metadata, COALESCE(day_key::text, ''), category, created_at + FROM chat_messages + WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL + ORDER BY id ASC + LIMIT $3 + `, sessionID, dayKey, limit) + if err == nil { + for rows.Next() { + var r ChatMessageRecord + var metaBytes []byte + if err := rows.Scan(&r.ID, &r.SessionID, &r.UserID, &r.Role, &r.Content, &r.Intent, &r.Branch, &metaBytes, &r.DayKey, &r.Category, &r.CreatedAt); err == nil { + if len(metaBytes) > 0 { + _ = json.Unmarshal(metaBytes, &r.Metadata) + } + msgs = append(msgs, r) + } + } + rows.Close() + } + } + + if len(msgs) > 0 { + minID := msgs[0].ID + maxID := msgs[len(msgs)-1].ID + _ = m.pool.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id=$1 AND day_key=$2::date AND id < $3 AND deleted_at IS NULL) + `, sessionID, dayKey, minID).Scan(&hasMoreBefore) + _ = m.pool.QueryRow(ctx, ` + SELECT EXISTS(SELECT 1 FROM chat_messages WHERE session_id=$1 AND day_key=$2::date AND id > $3 AND deleted_at IS NULL) + `, sessionID, dayKey, maxID).Scan(&hasMoreAfter) + } + + return &ChatDayMessagesResult{ + SessionID: sessionID, + DayKey: dayKey, + TotalCount: totalCount, + LoadedCount: len(msgs), + HasMoreBefore: hasMoreBefore, + HasMoreAfter: hasMoreAfter, + Messages: msgs, + }, nil +} + +func (m *ContextManager) getChatDayMessagesMem(ctx context.Context, sessionID, dayKey string, anchorID int64, beforeID, afterID int64, limit int) (*ChatDayMessagesResult, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + allMsgs := m.messages[sessionID] + var dayMsgs []ChatMessageRecord + for _, msg := range allMsgs { + if msg.DeletedAt != nil { + continue + } + dKey := msg.DayKey + if dKey == "" { + dKey = msg.CreatedAt.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02") + } + if dKey == dayKey { + dayMsgs = append(dayMsgs, msg) + } + } + + totalCount := len(dayMsgs) + if totalCount == 0 { + return &ChatDayMessagesResult{ + SessionID: sessionID, + DayKey: dayKey, + TotalCount: 0, + LoadedCount: 0, + HasMoreBefore: false, + HasMoreAfter: false, + Messages: []ChatMessageRecord{}, + }, nil + } + + var selected []ChatMessageRecord + if anchorID > 0 { + anchorIdx := -1 + for i, msg := range dayMsgs { + if msg.ID == anchorID { + anchorIdx = i + break + } + } + if anchorIdx < 0 { + // 若未精确找到 anchorID,按接近位置居中截取 + for i, msg := range dayMsgs { + if msg.ID >= anchorID { + anchorIdx = i + break + } + } + if anchorIdx < 0 { + anchorIdx = len(dayMsgs) - 1 + } + } + + half := limit / 2 + start := anchorIdx - half + if start < 0 { + start = 0 + } + end := start + limit + if end > len(dayMsgs) { + end = len(dayMsgs) + } + selected = dayMsgs[start:end] + } else if beforeID > 0 { + endIdx := -1 + for i, msg := range dayMsgs { + if msg.ID >= beforeID { + endIdx = i + break + } + } + if endIdx < 0 { + endIdx = len(dayMsgs) + } + start := endIdx - limit + if start < 0 { + start = 0 + } + selected = dayMsgs[start:endIdx] + } else if afterID > 0 { + startIdx := -1 + for i, msg := range dayMsgs { + if msg.ID > afterID { + startIdx = i + break + } + } + if startIdx >= 0 { + end := startIdx + limit + if end > len(dayMsgs) { + end = len(dayMsgs) + } + selected = dayMsgs[startIdx:end] + } + } else { + end := limit + if end > len(dayMsgs) { + end = len(dayMsgs) + } + selected = dayMsgs[:end] + } + + copied := make([]ChatMessageRecord, len(selected)) + copy(copied, selected) + + hasMoreBefore := false + hasMoreAfter := false + if len(copied) > 0 { + hasMoreBefore = copied[0].ID > dayMsgs[0].ID + hasMoreAfter = copied[len(copied)-1].ID < dayMsgs[len(dayMsgs)-1].ID + } + + return &ChatDayMessagesResult{ + SessionID: sessionID, + DayKey: dayKey, + TotalCount: totalCount, + LoadedCount: len(copied), + HasMoreBefore: hasMoreBefore, + HasMoreAfter: hasMoreAfter, + Messages: copied, + }, nil +} + +// ListChatDates 列出会话有消息的所有日期及分类统计 (日历打点使用) +func (m *ContextManager) ListChatDates(ctx context.Context, sessionID string) ([]ChatDateStat, error) { + if sessionID == "" { + return nil, errors.New("session_id required") + } + + if m.pool != nil { + rows, err := m.pool.Query(ctx, ` + SELECT day_key::text, count(*), category + FROM chat_messages + WHERE session_id = $1 AND deleted_at IS NULL AND day_key IS NOT NULL + GROUP BY day_key, category + ORDER BY day_key ASC + `, sessionID) + if err != nil { + return nil, fmt.Errorf("list chat dates failed: %w", err) + } + defer rows.Close() + + statMap := make(map[string]*ChatDateStat) + var orderedKeys []string + + for rows.Next() { + var dayStr, cat string + var cnt int + if err := rows.Scan(&dayStr, &cnt, &cat); err == nil { + st, ok := statMap[dayStr] + if !ok { + st = &ChatDateStat{ + DayKey: dayStr, + Count: 0, + Categories: make(map[string]int), + } + statMap[dayStr] = st + orderedKeys = append(orderedKeys, dayStr) + } + st.Count += cnt + st.Categories[cat] += cnt + } + } + + res := make([]ChatDateStat, 0, len(orderedKeys)) + for _, k := range orderedKeys { + res = append(res, *statMap[k]) + } + return res, nil + } + + m.mu.RLock() + defer m.mu.RUnlock() + + allMsgs := m.messages[sessionID] + statMap := make(map[string]*ChatDateStat) + var orderedKeys []string + + for _, msg := range allMsgs { + if msg.DeletedAt != nil { + continue + } + dKey := msg.DayKey + if dKey == "" { + dKey = msg.CreatedAt.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02") + } + st, ok := statMap[dKey] + if !ok { + st = &ChatDateStat{ + DayKey: dKey, + Count: 0, + Categories: make(map[string]int), + } + statMap[dKey] = st + orderedKeys = append(orderedKeys, dKey) + } + st.Count++ + cat := msg.Category + if cat == "" { + cat = CategoryUncategorized + } + st.Categories[cat]++ + } + + sort.Strings(orderedKeys) + res := make([]ChatDateStat, 0, len(orderedKeys)) + for _, k := range orderedKeys { + res = append(res, *statMap[k]) + } + return res, nil +} diff --git a/internal/ctxmgr/chat_window_test.go b/internal/ctxmgr/chat_window_test.go new file mode 100644 index 00000000..4658c712 --- /dev/null +++ b/internal/ctxmgr/chat_window_test.go @@ -0,0 +1,318 @@ +package ctxmgr_test + +import ( + "context" + "fmt" + "testing" + "time" + + "chat-agent-system/internal/ctxmgr" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestDeriveMessageCategory(t *testing.T) { + tests := []struct { + name string + role string + intent string + branch string + meta map[string]any + expected string + }{ + { + name: "milestone metadata", + role: "user", + meta: map[string]any{"milestone": true}, + expected: ctxmgr.CategoryMilestone, + }, + { + name: "attachments file op", + role: "user", + meta: map[string]any{"attachments": []any{"file.pdf"}}, + expected: ctxmgr.CategoryFile, + }, + { + name: "file_op export", + role: "assistant", + meta: map[string]any{"file_op": "export"}, + expected: ctxmgr.CategoryFile, + }, + { + name: "interactive_card review", + role: "assistant", + meta: map[string]any{"interactive_card": map[string]any{"type": "confirm_reject"}}, + expected: ctxmgr.CategoryReview, + }, + { + name: "task_request task", + role: "assistant", + meta: map[string]any{"task_request": map[string]any{"taskKey": "intake"}}, + expected: ctxmgr.CategoryTask, + }, + { + name: "DOCGOV intent review", + role: "user", + intent: "DOCGOV_REVIEW", + expected: ctxmgr.CategoryReview, + }, + { + name: "TRIGGER_TASK intent task", + role: "user", + intent: "TRIGGER_TASK", + expected: ctxmgr.CategoryTask, + }, + { + name: "QUERY_PROGRESS consult", + role: "user", + intent: "QUERY_PROGRESS", + expected: ctxmgr.CategoryConsult, + }, + { + name: "CASUAL_CHAT casual", + role: "user", + intent: "CASUAL_CHAT", + expected: ctxmgr.CategoryCasual, + }, + { + name: "system role system", + role: "system", + expected: ctxmgr.CategorySystem, + }, + { + name: "fallback uncategorized", + role: "user", + intent: "", + expected: ctxmgr.CategoryUncategorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := ctxmgr.DeriveMessageCategory(tt.role, tt.intent, tt.branch, tt.meta) + if actual != tt.expected { + t.Errorf("DeriveMessageCategory() = %s, want %s", actual, tt.expected) + } + }) + } +} + +func TestChatWindow_MemoryEquivalenceAndPagination(t *testing.T) { + mgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig()) + ctx := context.Background() + sessID := "sess_test_window_mem" + + // 1. 参数校验 + _, err := mgr.GetChatWindow(ctx, ctxmgr.ChatWindowQuery{SessionID: ""}) + if err == nil { + t.Errorf("expected error for empty sessionID") + } + + // 写入不同自然日的消息 + // Day 1: 2026-09-01 + // Day 2: 2026-09-02 + // ... + // Day 10: 2026-09-10 (包含 305 条消息触发截断) + shanghaiTZ := time.FixedZone("CST", 8*3600) + baseDate, _ := time.ParseInLocation("2006-01-02", "2026-09-01", shanghaiTZ) + + var targetAnchorID int64 + + for d := 0; d < 10; d++ { + curDate := baseDate.AddDate(0, 0, d) + curDayKey := curDate.Format("2006-01-02") + msgCount := 5 + if d == 9 { + msgCount = 310 // 触发 MaxMsgPerDay(300) 截断 + } + + for i := 0; i < msgCount; i++ { + msgTime := curDate.Add(time.Duration(i) * time.Minute) + meta := map[string]any{ + "day_key": curDayKey, + "created_at": msgTime, + } + id, err := mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", fmt.Sprintf("Day %s Msg %d", curDayKey, i), "QUERY_PROGRESS", "", meta) + if err != nil { + t.Fatalf("RecordUserMessageWithID failed: %v", err) + } + if d == 5 && i == 2 { + targetAnchorID = id + } + } + } + + // 2. 默认拉取 7 天窗口 + res, err := mgr.GetChatWindow(ctx, ctxmgr.ChatWindowQuery{ + SessionID: sessID, + Days: 7, + }) + if err != nil { + t.Fatalf("GetChatWindow failed: %v", err) + } + + if res.SessionID != sessID { + t.Errorf("expected SessionID %s, got %s", sessID, res.SessionID) + } + // 总共有 10 天,默认拉 7 天,应返回最近的 7 天 + if len(res.Days) != 7 { + t.Fatalf("expected 7 days loaded, got %d", len(res.Days)) + } + // 正序检查:最早日在前,最新日在后 + for i := 1; i < len(res.Days); i++ { + if res.Days[i-1].DayKey >= res.Days[i].DayKey { + t.Errorf("expected ascending day order: day[%d]=%s should be before day[%d]=%s", i-1, res.Days[i-1].DayKey, i, res.Days[i].DayKey) + } + } + // 验证还有更早日 + if !res.HasMoreBefore { + t.Errorf("expected has_more_before to be true") + } + oldestInWindow := res.OldestLoadedDay + + // 验证最新的一天(Day 10)发生截断 + latestDay := res.Days[len(res.Days)-1] + if latestDay.Count != 310 { + t.Errorf("expected Day 10 count 310, got %d", latestDay.Count) + } + if !latestDay.Truncated { + t.Errorf("expected Day 10 truncated to be true") + } + if latestDay.LoadedCount != 300 { + t.Errorf("expected loaded_count 300, got %d", latestDay.LoadedCount) + } + + // 3. 触顶加载更早日 (before_day) + olderRes, err := mgr.GetChatWindow(ctx, ctxmgr.ChatWindowQuery{ + SessionID: sessID, + Days: 7, + BeforeDay: oldestInWindow, + }) + if err != nil { + t.Fatalf("GetChatWindow before_day failed: %v", err) + } + // 剩余 3 天 + if len(olderRes.Days) != 3 { + t.Errorf("expected 3 older days, got %d", len(olderRes.Days)) + } + // 此段不应再有更早日 + if olderRes.HasMoreBefore { + t.Errorf("expected olderRes.HasMoreBefore to be false") + } + + // 4. ListChatDates 统计测试 + dates, err := mgr.ListChatDates(ctx, sessID) + if err != nil { + t.Fatalf("ListChatDates failed: %v", err) + } + if len(dates) != 10 { + t.Fatalf("expected 10 dates, got %d", len(dates)) + } + // 验证分类统计 + foundDay10 := false + for _, dt := range dates { + if dt.Count == 310 { + foundDay10 = true + if dt.Categories[ctxmgr.CategoryConsult] != 310 { + t.Errorf("expected 310 consult messages in day 10 stats, got %d", dt.Categories[ctxmgr.CategoryConsult]) + } + } + } + if !foundDay10 { + t.Errorf("day 10 with 310 messages not found in date stats") + } + + // 5. GetChatDayMessages 锚点上下文居中测试 + day5Key := baseDate.AddDate(0, 0, 5).Format("2006-01-02") + day5Res, err := mgr.GetChatDayMessages(ctx, sessID, day5Key, targetAnchorID, 0, 0, 4) + if err != nil { + t.Fatalf("GetChatDayMessages failed: %v", err) + } + if day5Res.TotalCount != 5 { + t.Errorf("expected total 5 messages in Day 5, got %d", day5Res.TotalCount) + } + // limit 4 -> returned 4 + if len(day5Res.Messages) != 4 { + t.Errorf("expected 4 messages returned around anchor, got %d", len(day5Res.Messages)) + } +} + +func TestChatWindow_DatabaseIntegration(t *testing.T) { + dsn := "postgres://app:dev_secret_db_pass@127.0.0.1:5433/agent_system_dev?sslmode=disable" + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Skipf("cannot connect to context db (%v), skipping DB integration test", err) + return + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + t.Skipf("context db ping failed (%v), skipping DB integration test", err) + return + } + + mgr := ctxmgr.NewContextManager(pool, nil, ctxmgr.DefaultTimeDecayConfig()) + sessID := fmt.Sprintf("sess_db_window_%d", time.Now().UnixNano()) + + // 插入跨越两天的测试消息 + shanghaiTZ := time.FixedZone("CST", 8*3600) + d1 := time.Now().In(shanghaiTZ).AddDate(0, 0, -1) + d2 := time.Now().In(shanghaiTZ) + d1Key := d1.Format("2006-01-02") + d2Key := d2.Format("2006-01-02") + + id1, err := mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "昨天立项需求沟通", "SUPPLEMENT_INFO", "", map[string]any{ + "day_key": d1Key, + "created_at": d1, + }) + if err != nil { + t.Fatalf("RecordUserMessageWithID d1 failed: %v", err) + } + + id2, err := mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "今天补充型检报告", "QUERY_REGULATIONS", "", map[string]any{ + "day_key": d2Key, + "created_at": d2, + }) + if err != nil { + t.Fatalf("RecordUserMessageWithID d2 failed: %v", err) + } + + // 1. 测试真实 DB 的 GetChatWindow + winRes, err := mgr.GetChatWindow(ctx, ctxmgr.ChatWindowQuery{ + SessionID: sessID, + Days: 7, + }) + if err != nil { + t.Fatalf("GetChatWindow failed: %v", err) + } + if len(winRes.Days) != 2 { + t.Fatalf("expected 2 days in window, got %d", len(winRes.Days)) + } + if winRes.Days[0].DayKey != d1Key || winRes.Days[1].DayKey != d2Key { + t.Errorf("expected sorted days [%s, %s], got [%s, %s]", d1Key, d2Key, winRes.Days[0].DayKey, winRes.Days[1].DayKey) + } + if winRes.Days[0].Messages[0].ID != id1 || winRes.Days[1].Messages[0].ID != id2 { + t.Errorf("expected message IDs [%d, %d], got [%d, %d]", id1, id2, winRes.Days[0].Messages[0].ID, winRes.Days[1].Messages[0].ID) + } + + // 2. 测试真实 DB 的 ListChatDates + dateStats, err := mgr.ListChatDates(ctx, sessID) + if err != nil { + t.Fatalf("ListChatDates failed: %v", err) + } + if len(dateStats) != 2 { + t.Fatalf("expected 2 date stats, got %d", len(dateStats)) + } + + // 3. 测试真实 DB 的 GetChatDayMessages + dayMsgs, err := mgr.GetChatDayMessages(ctx, sessID, d1Key, id1, 0, 0, 10) + if err != nil { + t.Fatalf("GetChatDayMessages failed: %v", err) + } + if dayMsgs.TotalCount != 1 || len(dayMsgs.Messages) != 1 || dayMsgs.Messages[0].ID != id1 { + t.Errorf("expected day 1 message ID %d, got %v", id1, dayMsgs) + } +} diff --git a/internal/ctxmgr/manager.go b/internal/ctxmgr/manager.go index 28733977..59466992 100644 --- a/internal/ctxmgr/manager.go +++ b/internal/ctxmgr/manager.go @@ -80,10 +80,35 @@ func (m *ContextManager) SetEmbedder(emb VectorEmbedder) { // RecordUserMessage 记录单轮对话消息并自动驱动工作习惯偏好更新 func (m *ContextManager) RecordUserMessage(ctx context.Context, sessionID string, userID int64, role, content, intent, branch string, meta map[string]any) error { + _, err := m.RecordUserMessageWithID(ctx, sessionID, userID, role, content, intent, branch, meta) + return err +} + +// RecordUserMessageWithID 记录单轮对话消息并返回生成的消息真实自增 ID +func (m *ContextManager) RecordUserMessageWithID(ctx context.Context, sessionID string, userID int64, role, content, intent, branch string, meta map[string]any) (int64, error) { if sessionID == "" || content == "" { - return errors.New("sessionID and content required") + return 0, errors.New("sessionID and content required") } + shanghaiTZ := time.FixedZone("CST", 8*3600) + now := time.Now() + dayKey := now.In(shanghaiTZ).Format("2006-01-02") + if meta != nil { + if customTime, ok := meta["created_at"].(time.Time); ok && !customTime.IsZero() { + now = customTime + dayKey = now.In(shanghaiTZ).Format("2006-01-02") + } else if customTimeStr, ok := meta["created_at"].(string); ok && customTimeStr != "" { + if tParsed, err := time.Parse(time.RFC3339, customTimeStr); err == nil { + now = tParsed + dayKey = now.In(shanghaiTZ).Format("2006-01-02") + } + } + if customDayKey, ok := meta["day_key"].(string); ok && customDayKey != "" { + dayKey = customDayKey + } + } + category := DeriveMessageCategory(role, intent, branch, meta) + rec := ChatMessageRecord{ SessionID: sessionID, UserID: userID, @@ -92,9 +117,13 @@ func (m *ContextManager) RecordUserMessage(ctx context.Context, sessionID string Intent: intent, Branch: branch, Metadata: meta, - CreatedAt: time.Now(), + DayKey: dayKey, + Category: category, + CreatedAt: now, } + var messageID int64 + if m.pool != nil { var uid *int64 if userID > 0 { @@ -121,16 +150,19 @@ func (m *ContextManager) RecordUserMessage(ctx context.Context, sessionID string `, sessionID, uid, pid) metaJSON, _ := json.Marshal(meta) - _, err := m.pool.Exec(ctx, ` - INSERT INTO chat_messages (session_id, user_id, role, content, intent, branch, metadata, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, now()) - `, sessionID, uid, role, content, intent, branch, metaJSON) + err := m.pool.QueryRow(ctx, ` + INSERT INTO chat_messages (session_id, user_id, role, content, intent, branch, metadata, day_key, category, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id + `, sessionID, uid, role, content, intent, branch, metaJSON, dayKey, category, now).Scan(&messageID) if err != nil { - return fmt.Errorf("insert chat message failed: %w", err) + return 0, fmt.Errorf("insert chat message failed: %w", err) } + rec.ID = messageID } else { m.mu.Lock() rec.ID = m.nextMessageID + messageID = rec.ID m.nextMessageID++ m.messages[sessionID] = append(m.messages[sessionID], rec) @@ -155,7 +187,7 @@ func (m *ContextManager) RecordUserMessage(ctx context.Context, sessionID string _ = m.ReflectUserPreference(ctx, userID, sessionID) } - return nil + return messageID, nil } // RecordActivity 记录项目协同操作/决策/上传/下载活动 diff --git a/internal/ctxmgr/types.go b/internal/ctxmgr/types.go index f91bfaab..d56d6134 100644 --- a/internal/ctxmgr/types.go +++ b/internal/ctxmgr/types.go @@ -107,9 +107,57 @@ type ChatMessageRecord struct { Intent string `json:"intent,omitempty"` Branch string `json:"branch,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` + DayKey string `json:"day_key"` + Category string `json:"category"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` CreatedAt time.Time `json:"created_at"` } +// ChatDayBucket 按日消息分片桶 +type ChatDayBucket struct { + DayKey string `json:"day_key"` // "2026-09-10" + Count int `json:"count"` // 当日消息总数(未截断) + LoadedCount int `json:"loaded_count"` // 本次实际返回条数 + Truncated bool `json:"truncated"` // 单日超 MAX_MSG_PER_DAY 截断标记 + FirstMessageID int64 `json:"first_message_id"` // 当日首条消息 ID + LastMessageID int64 `json:"last_message_id"` // 当日末条消息 ID + Messages []ChatMessageRecord `json:"messages"` // 正序 +} + +// ChatWindowQuery 按日窗口查询参数 +type ChatWindowQuery struct { + SessionID string `json:"session_id"` + Days int `json:"days"` // 默认 7, 上限 31 + BeforeDay string `json:"before_day"` // 触顶加载: 早于此日的下一段 (YYYY-MM-DD), 空则取最新 +} + +// ChatWindowResult 按日窗口查询结果 +type ChatWindowResult struct { + SessionID string `json:"session_id"` + Days []ChatDayBucket `json:"days"` // 正序日集合 + HasMoreBefore bool `json:"has_more_before"` // 是否存在早于 oldest_loaded_day 的消息 + OldestLoadedDay string `json:"oldest_loaded_day"` // 当前已加载日中最旧的 day_key +} + +// ChatDayMessagesResult 指定日消息拉取结果 +type ChatDayMessagesResult struct { + SessionID string `json:"session_id"` + DayKey string `json:"day_key"` + TotalCount int `json:"total_count"` + LoadedCount int `json:"loaded_count"` + HasMoreBefore bool `json:"has_more_before"` + HasMoreAfter bool `json:"has_more_after"` + Messages []ChatMessageRecord `json:"messages"` +} + +// ChatDateStat 会话日期统计 (用于日历打点) +type ChatDateStat struct { + DayKey string `json:"day_key"` + Count int `json:"count"` + Categories map[string]int `json:"categories"` + DigestStatus string `json:"digest_status,omitempty"` // 预留 +} + // BlendedSquadContext 智能体小队混合上下文 (用于注入到 Agent Prompt 中) type BlendedSquadContext struct { ProjectName string `json:"project_name"` diff --git a/internal/server/chat_window_api.go b/internal/server/chat_window_api.go new file mode 100644 index 00000000..58a824df --- /dev/null +++ b/internal/server/chat_window_api.go @@ -0,0 +1,182 @@ +package server + +import ( + "encoding/json" + "net/http" + "os" + "strconv" + + "chat-agent-system/internal/ctxmgr" +) + +// handleGetChatWindow 获取指定会话最近 N 日的消息窗口 +// GET /api/v1/chat/messages/window?session_id=&days=&before_day= +func (s *Server) handleGetChatWindow(w http.ResponseWriter, r *http.Request) { + if s.svc == nil || s.svc.ContextManager() == nil { + http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) + return + } + + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + http.Error(w, "missing session_id query param", http.StatusBadRequest) + return + } + + daysStr := r.URL.Query().Get("days") + days, _ := strconv.Atoi(daysStr) + if days <= 0 { + days = ctxmgr.DefaultWindowDays + } + beforeDay := r.URL.Query().Get("before_day") + + res, err := s.svc.ContextManager().GetChatWindow(r.Context(), ctxmgr.ChatWindowQuery{ + SessionID: sessionID, + Days: days, + BeforeDay: beforeDay, + }) + if err != nil { + http.Error(w, "get chat window failed: "+err.Error(), http.StatusInternalServerError) + return + } + + writeJSONResponse(w, http.StatusOK, res) +} + +// handleGetChatDayMessages 获取指定日消息 (支持 anchor_id 上下文补齐或游标分页) +// GET /api/v1/chat/messages/day?session_id=&date=&anchor_id=&before_id=&after_id=&limit= +func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request) { + if s.svc == nil || s.svc.ContextManager() == nil { + http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) + return + } + + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + http.Error(w, "missing session_id query param", http.StatusBadRequest) + return + } + date := r.URL.Query().Get("date") + if date == "" { + date = r.URL.Query().Get("day_key") + } + if date == "" { + http.Error(w, "missing date or day_key query param", http.StatusBadRequest) + return + } + + anchorIDStr := r.URL.Query().Get("anchor_id") + anchorID, _ := strconv.ParseInt(anchorIDStr, 10, 64) + + beforeIDStr := r.URL.Query().Get("before_id") + beforeID, _ := strconv.ParseInt(beforeIDStr, 10, 64) + + afterIDStr := r.URL.Query().Get("after_id") + afterID, _ := strconv.ParseInt(afterIDStr, 10, 64) + + limitStr := r.URL.Query().Get("limit") + limit, _ := strconv.Atoi(limitStr) + if limit <= 0 { + limit = 100 + } + + res, err := s.svc.ContextManager().GetChatDayMessages(r.Context(), sessionID, date, anchorID, beforeID, afterID, limit) + if err != nil { + http.Error(w, "get chat day messages failed: "+err.Error(), http.StatusInternalServerError) + return + } + + writeJSONResponse(w, http.StatusOK, res) +} + +// handleListChatDates 列出会话有消息的全部日期 (用于日历打点) +// GET /api/v1/chat/dates?session_id= +func (s *Server) handleListChatDates(w http.ResponseWriter, r *http.Request) { + if s.svc == nil || s.svc.ContextManager() == nil { + http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) + return + } + + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + http.Error(w, "missing session_id query param", http.StatusBadRequest) + return + } + + stats, err := s.svc.ContextManager().ListChatDates(r.Context(), sessionID) + if err != nil { + http.Error(w, "list chat dates failed: "+err.Error(), http.StatusInternalServerError) + return + } + + writeJSONResponse(w, http.StatusOK, stats) +} + +// handleImportChatMessages 一次性批量回填历史消息 (仅允许在 dev/demo 环境下调用) +// POST /api/v1/chat/messages/import +func (s *Server) handleImportChatMessages(w http.ResponseWriter, r *http.Request) { + if s.svc == nil || s.svc.ContextManager() == nil { + http.Error(w, "context manager not initialized", http.StatusServiceUnavailable) + return + } + + // 安全门禁:生产环境默认禁止调用,仅在 dev 或显式开启时开放 + isDev := s.cfg.Env == "dev" || s.cfg.Env == "development" || s.cfg.Env == "" || os.Getenv("ENABLE_DEV_IMPORT") == "true" + if !isDev { + http.Error(w, "chat messages import endpoint is only available in dev/demo environment", http.StatusForbidden) + return + } + + type importMessageItem struct { + SessionID string `json:"session_id"` + UserID int64 `json:"user_id"` + Role string `json:"role"` + Content string `json:"content"` + Intent string `json:"intent"` + Branch string `json:"branch"` + Metadata map[string]any `json:"metadata"` + DayKey string `json:"day_key"` + CreatedAt string `json:"created_at"` + } + + var items []importMessageItem + if err := json.NewDecoder(r.Body).Decode(&items); err != nil { + http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest) + return + } + + imported := 0 + for _, item := range items { + if item.SessionID == "" || item.Content == "" { + continue + } + if item.Metadata == nil { + item.Metadata = make(map[string]any) + } + if item.DayKey != "" { + item.Metadata["day_key"] = item.DayKey + } + if item.CreatedAt != "" { + item.Metadata["created_at"] = item.CreatedAt + } + + _, err := s.svc.ContextManager().RecordUserMessageWithID( + r.Context(), + item.SessionID, + item.UserID, + item.Role, + item.Content, + item.Intent, + item.Branch, + item.Metadata, + ) + if err == nil { + imported++ + } + } + + writeJSONResponse(w, http.StatusOK, map[string]any{ + "imported_count": imported, + "total": len(items), + }) +} diff --git a/internal/server/chat_window_api_test.go b/internal/server/chat_window_api_test.go new file mode 100644 index 00000000..98f26e07 --- /dev/null +++ b/internal/server/chat_window_api_test.go @@ -0,0 +1,160 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/ctxmgr" + "chat-agent-system/internal/server" +) + +func TestServer_ChatWindowAndDayAPIs(t *testing.T) { + cfg := &config.Config{ + Env: "dev", + HTTPHost: "127.0.0.1", + HTTPPort: 8889, + DataDir: t.TempDir(), + } + svc := bizdev.NewService(cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + + sessID := "sess_api_window_001" + userID := int64(201) + + // 1. 测试 POST /api/v1/chat/messages/import (批量历史回填) + importBody := []map[string]any{ + { + "session_id": sessID, + "user_id": userID, + "role": "user", + "content": "9月8日初次需求沟通:规划一款神经外科无线超声", + "intent": "SUPPLEMENT_INFO", + "branch": "", + "day_key": "2026-09-08", + "created_at": "2026-09-08T09:00:00+08:00", + }, + { + "session_id": sessID, + "user_id": 0, + "role": "assistant", + "content": "已记录需求,正在检索 NMPA 神经外科超声相关审评要点", + "intent": "ADVISOR_BRIEF", + "branch": "", + "day_key": "2026-09-08", + "created_at": "2026-09-08T09:00:15+08:00", + }, + { + "session_id": sessID, + "user_id": userID, + "role": "user", + "content": "9月10日最新进展:请生成技术方案初步草案", + "intent": "TRIGGER_TASK", + "branch": "", + "day_key": "2026-09-10", + "created_at": "2026-09-10T10:00:00+08:00", + }, + } + bodyBytes, _ := json.Marshal(importBody) + + reqImport := httptest.NewRequest("POST", "/api/v1/chat/messages/import", bytes.NewReader(bodyBytes)) + rrImport := httptest.NewRecorder() + handler.ServeHTTP(rrImport, reqImport) + + if rrImport.Code != http.StatusOK { + t.Fatalf("POST /api/v1/chat/messages/import expected 200, got %d: %s", rrImport.Code, rrImport.Body.String()) + } + var importResp map[string]any + _ = json.Unmarshal(rrImport.Body.Bytes(), &importResp) + if int(importResp["imported_count"].(float64)) != 3 { + t.Fatalf("expected 3 messages imported, got %v", importResp["imported_count"]) + } + + // 2. 测试 GET /api/v1/chat/messages/window + reqWindow := httptest.NewRequest("GET", "/api/v1/chat/messages/window?session_id="+sessID+"&days=7", nil) + rrWindow := httptest.NewRecorder() + handler.ServeHTTP(rrWindow, reqWindow) + + if rrWindow.Code != http.StatusOK { + t.Fatalf("GET /api/v1/chat/messages/window expected 200, got %d: %s", rrWindow.Code, rrWindow.Body.String()) + } + + var winResult ctxmgr.ChatWindowResult + if err := json.Unmarshal(rrWindow.Body.Bytes(), &winResult); err != nil { + t.Fatalf("unmarshal ChatWindowResult failed: %v", err) + } + + if winResult.SessionID != sessID { + t.Errorf("expected session_id %s, got %s", sessID, winResult.SessionID) + } + if len(winResult.Days) != 2 { + t.Fatalf("expected 2 days in window, got %d", len(winResult.Days)) + } + if winResult.Days[0].DayKey != "2026-09-08" || winResult.Days[1].DayKey != "2026-09-10" { + t.Errorf("expected days [2026-09-08, 2026-09-10], got [%s, %s]", winResult.Days[0].DayKey, winResult.Days[1].DayKey) + } + if len(winResult.Days[0].Messages) != 2 || len(winResult.Days[1].Messages) != 1 { + t.Errorf("unexpected day message counts: day0=%d, day1=%d", len(winResult.Days[0].Messages), len(winResult.Days[1].Messages)) + } + + // 3. 测试 GET /api/v1/chat/dates + reqDates := httptest.NewRequest("GET", "/api/v1/chat/dates?session_id="+sessID, nil) + rrDates := httptest.NewRecorder() + handler.ServeHTTP(rrDates, reqDates) + + if rrDates.Code != http.StatusOK { + t.Fatalf("GET /api/v1/chat/dates expected 200, got %d: %s", rrDates.Code, rrDates.Body.String()) + } + var dateStats []ctxmgr.ChatDateStat + if err := json.Unmarshal(rrDates.Body.Bytes(), &dateStats); err != nil { + t.Fatalf("unmarshal ChatDateStat failed: %v", err) + } + if len(dateStats) != 2 { + t.Fatalf("expected 2 dates, got %d", len(dateStats)) + } + if dateStats[0].Count != 2 || dateStats[1].Count != 1 { + t.Errorf("unexpected date message counts: %v", dateStats) + } + + // 4. 测试 GET /api/v1/chat/messages/day + reqDay := httptest.NewRequest("GET", "/api/v1/chat/messages/day?session_id="+sessID+"&date=2026-09-08", nil) + rrDay := httptest.NewRecorder() + handler.ServeHTTP(rrDay, reqDay) + + if rrDay.Code != http.StatusOK { + t.Fatalf("GET /api/v1/chat/messages/day expected 200, got %d: %s", rrDay.Code, rrDay.Body.String()) + } + var dayRes ctxmgr.ChatDayMessagesResult + if err := json.Unmarshal(rrDay.Body.Bytes(), &dayRes); err != nil { + t.Fatalf("unmarshal ChatDayMessagesResult failed: %v", err) + } + if dayRes.DayKey != "2026-09-08" || dayRes.TotalCount != 2 || len(dayRes.Messages) != 2 { + t.Fatalf("unexpected day messages result: %+v", dayRes) + } +} + +func TestServer_ChatWindow_ImportGuard(t *testing.T) { + // 验证在生产环境中 import 接口被正确拦截 + cfg := &config.Config{ + Env: "production", + HTTPHost: "127.0.0.1", + HTTPPort: 8889, + DataDir: t.TempDir(), + } + svc := bizdev.NewService(cfg) + srv := server.NewServer(cfg, svc) + handler := srv.Routes() + + reqImport := httptest.NewRequest("POST", "/api/v1/chat/messages/import", bytes.NewReader([]byte("[]"))) + rrImport := httptest.NewRecorder() + handler.ServeHTTP(rrImport, reqImport) + + if rrImport.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden in production env, got %d", rrImport.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index ce04afc9..be9c0176 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -94,6 +94,10 @@ func (s *Server) Routes() http.Handler { // 7. 历史多轮会话管理与标杆案例语义检索 mux.HandleFunc("GET /api/v1/chat/sessions", s.handleListChatSessions) mux.HandleFunc("GET /api/v1/chat/messages", s.handleGetChatMessages) + mux.HandleFunc("GET /api/v1/chat/messages/window", s.handleGetChatWindow) + mux.HandleFunc("GET /api/v1/chat/messages/day", s.handleGetChatDayMessages) + mux.HandleFunc("GET /api/v1/chat/dates", s.handleListChatDates) + mux.HandleFunc("POST /api/v1/chat/messages/import", s.handleImportChatMessages) mux.HandleFunc("GET /api/v1/context/cases/recommend", s.handleRecommendCases) // 8. 受控文档编制、审核、批准生命周期与认领治理 diff --git a/scripts/build_long_history_seed.py b/scripts/build_long_history_seed.py new file mode 100755 index 00000000..b0b8071b --- /dev/null +++ b/scripts/build_long_history_seed.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +build_long_history_seed.py +============================================================================= +长对话历史 90 天 / 12,000 条压测种子生成脚本 +用于验证 P0 性能预算: + 1. 打开包含 12,000 条消息的长会话,P95 首屏加载至落底 ≤ 300ms + 2. 窗口只拉取最近 7 天 (可配置),瞬时落底无平滑滚动动画过程 + 3. 单日超额截断 (单日 500 条) 与 DayDivider 截断标记验证 + 4. 触顶滚动补偿平滑加载更早日期分片 +============================================================================= +""" + +import sys +import os +import io +import json +import random +import subprocess +from datetime import datetime, timedelta, timezone + +SESSION_ID = "sess_benchmark_long_001" +DAYS_COUNT = 90 +TOTAL_TARGET = 12000 + +SAMPLE_INTENTS = [ + ("SUPPLEMENT_INFO", "consult", "user"), + ("QUERY_PROGRESS", "consult", "user"), + ("QUERY_REGULATIONS", "consult", "user"), + ("CASUAL_CHAT", "casual", "user"), + ("TRIGGER_TASK", "task", "user"), + ("DOCGOV_REVIEW", "review", "user"), +] + +SAMPLE_USER_PROMPTS = [ + "我们准备立项一款无线便携超声设备,请评估注册分类与型检周期。", + "请查询 GB 9706.1-2020 关于患者漏电流和绝缘阻抗的强制性条款。", + "超声探头手柄外壳采用什么等级的医用 PC 塑料能通过生物相容性?", + "帮我对比一下国内迈瑞和飞利浦同类掌超产品的技术指标与主要卖点。", + "关于电气安全和电磁兼容(EMC),型检摸底测试通常需要多长时间?", + "项目进入可行性评审阶段,请同步准备初审材料和研发工时预算表。", + "收到,下午两点我们与检验所专家开视频会对齐抽检样机数量。", + "请帮我梳理一份向高管汇报的 5 分钟总结,重点突出风险和预算缺口。", + "好的,没问题,先按既定计划推进第二轮软件单元测试。", + "辛苦尽快起草一份注册路径初筛评估报告,我等下要向质量负责人汇报。", +] + +SAMPLE_ASST_REPLIES = [ + "收到。便携无线超声诊断仪根据《医疗器械分类目录》,通常划分为第二类或第三类医疗器械(代码 07-01)。型检周期预估为 4-6 个月。", + "根据 GB 9706.1-2020 标准第 8.7 条规定,应用部分在正常状态下对地漏电流限值为 100μA,单一故障状态下为 500μA。", + "建议选用符合 ISO 10993-1 生物相容性标准的医用级聚碳酸酯(PC,如 Covestro Makrolon 2858),需完成细胞毒性、致敏与皮内刺激测试。", + "已为您完成竞品多维比对:标杆竞品主机重量约 220g,单次续航 4 小时,支持 B/M/Color 多普勒模式;我们自研方案在功耗散热上具备明显优势。", + "建议预留 3-4 周进行摸底整改,重点关注 YY 9706.102-2021 中静电放电(ESD)±8kV 接触放电与辐射发射(RE)余量。", + "已为您在项目协作区生成《数字化便携超声注册路径可行性报告(初稿)》,关键排期节点已同步更新至项目协同看板。", + "好的,会议要点已自动记录至协同黑板,稍后将为您生成会议纪要与待办任务清单。", +] + + +def generate_seed_csv(): + # 统一使用中国标准时区 (Asia/Shanghai, UTC+8) + tz = timezone(timedelta(hours=8)) + now = datetime.now(tz) + start_date = now - timedelta(days=DAYS_COUNT - 1) + + print(f"🚀 开始生成 90 天压测种子数据...") + print(f" 会话 ID: {SESSION_ID}") + print(f" 日期范围: {start_date.strftime('%Y-%m-%d')} ~ {now.strftime('%Y-%m-%d')}") + print(f" 目标总数: 约 {TOTAL_TARGET} 条") + + # 确定每日消息条数: + # 普通日:110~130 条 + # 高峰日(如 Day 30, Day 60, Day 85):450~550 条(触发 >300 截断) + day_counts = [] + for d in range(DAYS_COUNT): + if d in (30, 60, 85): + day_counts.append(random.randint(450, 520)) + else: + day_counts.append(random.randint(110, 135)) + + total_planned = sum(day_counts) + print(f" 实际规划条数: {total_planned} 条 (包含 3 个单日超 500 条的高频爆量日)") + + output = io.StringIO() + msg_id = 1 + + for d, count in enumerate(day_counts): + day_dt = start_date + timedelta(days=d) + day_key = day_dt.strftime("%Y-%m-%d") + + for i in range(count): + # 时间按当天工作时间均匀分布 (08:30 ~ 21:30) + seconds_offset = int((i / max(1, count)) * 46800) + msg_dt = day_dt.replace(hour=8, minute=30, second=0) + timedelta(seconds=seconds_offset) + ts_str = msg_dt.strftime("%Y-%m-%d %H:%M:%S%z") + + # 偶数用户,奇数助手 + if i % 2 == 0: + role = "user" + uid_str = "1" + intent, category, _ = random.choice(SAMPLE_INTENTS) + content = random.choice(SAMPLE_USER_PROMPTS) + f" (d{d}-m{i})" + meta = {"author_name": "张工"} + if category == "file": + meta["attachments"] = [{"name": "需求规格说明书.pdf", "size": "3.5MB", "type": "pdf"}] + elif d % 15 == 0 and i == 0: + category = "milestone" + meta["milestone"] = True + else: + role = "assistant" + uid_str = r"\N" + intent = "ADVISOR_BRIEF" + category = "consult" + content = random.choice(SAMPLE_ASST_REPLIES) + f" [参考编号 CDMO-{day_key}-{i:03d}]" + meta = {"author_name": "项目助手"} + + meta_json = json.dumps(meta, ensure_ascii=False) + + # CSV: session_id, user_id, role, content, intent, branch, metadata, day_key, category, created_at + # 使用制表符作为分隔符 + content_clean = content.replace("\t", " ").replace("\n", " ") + meta_clean = meta_json.replace("\t", " ").replace("\n", " ") + + output.write( + f"{SESSION_ID}\t{uid_str}\t{role}\t{content_clean}\t{intent}\t\t{meta_clean}\t{day_key}\t{category}\t{ts_str}\n" + ) + msg_id += 1 + + return output.getvalue(), total_planned + + +def import_to_postgres(csv_data, total_count): + print(f"📦 正在通过 psql COPY 流式注入容器 shuimu-context-pg-dev...") + + # 1. 确保 chat_sessions 存在 + ensure_session_sql = f""" + INSERT INTO chat_sessions (session_id, user_id, project_id, title, created_at, updated_at) + VALUES ('{SESSION_ID}', 101, 1, '90天万条长历史压测基准会话', now(), now()) + ON CONFLICT (session_id) DO UPDATE SET updated_at = now(); + DELETE FROM chat_messages WHERE session_id = '{SESSION_ID}'; + """ + + subprocess.run( + [ + "docker", "exec", "-i", "shuimu-context-pg-dev", + "psql", "-U", "app", "-d", "agent_system_dev", "-c", ensure_session_sql + ], + check=True, + capture_output=True, + ) + + # 2. 执行 COPY 导入 + copy_sql = r""" + COPY chat_messages (session_id, user_id, role, content, intent, branch, metadata, day_key, category, created_at) FROM STDIN WITH (FORMAT text, DELIMITER E'\t'); + """ + + p = subprocess.Popen( + [ + "docker", "exec", "-i", "shuimu-context-pg-dev", + "psql", "-U", "app", "-d", "agent_system_dev", "-c", copy_sql + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + stdout, stderr = p.communicate(input=csv_data) + if p.returncode != 0: + print(f"❌ 导入失败: {stderr}", file=sys.stderr) + sys.exit(1) + + print(f"✅ 成功导入 {total_count} 条消息!") + + # 3. 统计验证 + verify_cmd = [ + "docker", "exec", "-i", "shuimu-context-pg-dev", + "psql", "-U", "app", "-d", "agent_system_dev", "-c", + f""" + SELECT + count(*) as total_msgs, + count(DISTINCT day_key) as total_days, + min(day_key) as earliest_day, + max(day_key) as latest_day + FROM chat_messages + WHERE session_id = '{SESSION_ID}'; + """ + ] + res = subprocess.check_output(verify_cmd, text=True) + print("\n📊 数据库验证统计:") + print(res) + + +if __name__ == "__main__": + csv_data, total_planned = generate_seed_csv() + import_to_postgres(csv_data, total_planned) + print("🎉 90天万条长历史压测数据准备完毕!") diff --git a/web/src/App.tsx b/web/src/App.tsx index e855a315..693050aa 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -76,7 +76,9 @@ import { saveRoles, loadUsers, saveUsers, + migrateAndClearLegacyMessages, } from './data/storage'; +import { useChatWindow } from './hooks/useChatWindow'; export const INITIAL_DELIVERABLES: DeliverableItem[] = [ { @@ -740,6 +742,17 @@ export default function App() { return messages.filter((m) => m.sessionId === activeSessionId); }, [messages, activeSessionId]); + // P0: 会话按日窗口状态机 (处理首屏拉取、瞬时落底、触顶加载补偿) + const chatWindow = useChatWindow({ + sessionId: activeSessionId, + liveMessages: currentSessionMessages, + }); + + // 挂载时检测并迁移旧版 localStorage 巨大 MESSAGES 键 + useEffect(() => { + migrateAndClearLegacyMessages(); + }, []); + // 当前会话是否存在正在等待/流式/自动重试中的助手气泡 (驱动 InputArea 停止态) const isStreamingActive = useMemo(() => { return currentSessionMessages.some( @@ -1032,7 +1045,15 @@ export default function App() { } case 'done': { settled = true; - updateAsst({ status: 'completed', livePhaseLabel: undefined }); + const serverMsgId = ev.message_id || (ev as any).messageId; + if (typeof serverMsgId === 'number' && serverMsgId > 0) { + chatWindow.replaceMessageServerId(asstId, serverMsgId); + } + updateAsst({ + status: 'completed', + livePhaseLabel: undefined, + serverId: typeof serverMsgId === 'number' ? serverMsgId : undefined, + }); setIsThinking(false); break; } @@ -1787,7 +1808,11 @@ export default function App() { chatContent={
; + day_key: string; + category?: any; + created_at: string; +} + +export interface RawChatDayBucket { + day_key: string; + count: number; + loaded_count: number; + truncated: boolean; + first_message_id: number; + last_message_id: number; + messages: RawChatMessageRecord[]; +} + +export interface RawChatWindowResult { + session_id: string; + days: RawChatDayBucket[]; + has_more_before: boolean; + oldest_loaded_day: string; +} + +export interface ChatDayMessagesResult { + sessionId: string; + dayKey: string; + totalCount: number; + loadedCount: number; + hasMoreBefore: boolean; + hasMoreAfter: boolean; + messages: ChatMessage[]; +} + +export function mapRecordToMessage(r: RawChatMessageRecord): ChatMessage { + const timestamp = r.created_at + ? new Date(r.created_at).toTimeString().substring(0, 5) + : new Date().toTimeString().substring(0, 5); + + const meta = r.metadata || {}; + return { + id: r.id ? `msg-${r.id}` : `msg-tmp-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`, + serverId: r.id, + sessionId: r.session_id, + role: r.role, + authorName: meta.author_name || (r.role === 'user' ? '用户' : '项目助手'), + content: r.content || '', + intentTag: r.intent || undefined, + branchTag: r.branch || undefined, + dayKey: r.day_key, + category: r.category, + timestamp, + createdAt: r.created_at, + attachments: meta.attachments, + interactiveCard: meta.interactive_card, + recommendedCases: meta.recommended_cases, + quotes: meta.quotes || (meta.quote ? [meta.quote] : undefined), + status: r.role === 'assistant' ? 'completed' : undefined, + }; +} + +export function mapRawBucket(b: RawChatDayBucket): ChatDayBucket { + return { + dayKey: b.day_key, + count: b.count, + loadedCount: b.loaded_count, + truncated: b.truncated, + firstMessageId: b.first_message_id, + lastMessageId: b.last_message_id, + messages: (b.messages || []).map(mapRecordToMessage), + }; +} + +export async function fetchChatWindow( + sessionId: string, + days: number = 7, + beforeDay?: string +): Promise { + let url = `/api/v1/chat/messages/window?session_id=${encodeURIComponent(sessionId)}&days=${days}`; + if (beforeDay) { + url += `&before_day=${encodeURIComponent(beforeDay)}`; + } + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`fetchChatWindow failed: HTTP ${res.status}`); + } + + const raw: RawChatWindowResult = await res.json(); + return { + sessionId: raw.session_id, + days: (raw.days || []).map(mapRawBucket), + hasMoreBefore: Boolean(raw.has_more_before), + oldestLoadedDay: raw.oldest_loaded_day || '', + }; +} + +export async function fetchDayMessages( + sessionId: string, + date: string, + options?: { + anchorId?: number; + beforeId?: number; + afterId?: number; + limit?: number; + } +): Promise { + const params = new URLSearchParams({ + session_id: sessionId, + date, + }); + if (options?.anchorId) params.set('anchor_id', String(options.anchorId)); + if (options?.beforeId) params.set('before_id', String(options.beforeId)); + if (options?.afterId) params.set('after_id', String(options.afterId)); + if (options?.limit) params.set('limit', String(options.limit)); + + const res = await fetch(`/api/v1/chat/messages/day?${params.toString()}`); + if (!res.ok) { + throw new Error(`fetchDayMessages failed: HTTP ${res.status}`); + } + + const raw = await res.json(); + return { + sessionId: raw.session_id, + dayKey: raw.day_key, + totalCount: raw.total_count || 0, + loadedCount: raw.loaded_count || 0, + hasMoreBefore: Boolean(raw.has_more_before), + hasMoreAfter: Boolean(raw.has_more_after), + messages: (raw.messages || []).map(mapRecordToMessage), + }; +} + +export async function fetchChatDates(sessionId: string): Promise { + const res = await fetch(`/api/v1/chat/dates?session_id=${encodeURIComponent(sessionId)}`); + if (!res.ok) { + throw new Error(`fetchChatDates failed: HTTP ${res.status}`); + } + const raw = await res.json(); + return (raw || []).map((item: any) => ({ + dayKey: item.day_key, + count: item.count || 0, + categories: item.categories || {}, + digestStatus: item.digest_status, + })); +} + +export async function importChatMessages( + messages: Array> +): Promise<{ imported_count: number; total: number }> { + const res = await fetch('/api/v1/chat/messages/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(messages), + }); + if (!res.ok) { + throw new Error(`importChatMessages failed: HTTP ${res.status}`); + } + return res.json(); +} diff --git a/web/src/components/chat/MessageBubble.tsx b/web/src/components/chat/MessageBubble.tsx index adc4c152..457f4d67 100644 --- a/web/src/components/chat/MessageBubble.tsx +++ b/web/src/components/chat/MessageBubble.tsx @@ -56,6 +56,9 @@ export const MessageBubble: React.FC = ({ return (
void; + onLoadFullDay?: (dayKey: string) => void; onOpenCase?: (docId: string) => void; onCardAction?: (cardId: string, actionId: string, payload?: any) => void; onErrorAction?: (messageId: string, action: 'retry' | 'continue' | 'restart') => void; @@ -11,27 +18,121 @@ export interface MessageStreamProps { export const MessageStream: React.FC = ({ messages, + dayStack, + isLoadingOlder = false, + hasMoreBefore = false, + onLoadOlder, + onLoadFullDay, onOpenCase, onCardAction, onErrorAction, }) => { - const bottomRef = useRef(null); + const containerRef = useRef(null); + const isInitialMountRef = useRef(true); + const prevMsgCountRef = useRef(0); + const isNearBottomRef = useRef(true); + // 计算当前有效日分片集 + const activeBuckets: ChatDayBucket[] = React.useMemo(() => { + if (dayStack && dayStack.length > 0) { + return dayStack; + } + if (messages && messages.length > 0) { + // 兼容老调用:将 messages 包装为单个桶 + return [ + { + dayKey: messages[0].dayKey || '', + count: messages.length, + loadedCount: messages.length, + truncated: false, + firstMessageId: messages[0].serverId || 0, + lastMessageId: messages[messages.length - 1].serverId || 0, + messages, + }, + ]; + } + return []; + }, [dayStack, messages]); + + const totalMessagesCount = React.useMemo(() => { + return activeBuckets.reduce((sum, b) => sum + b.messages.length, 0); + }, [activeBuckets]); + + // 监听用户当前是否在底部 + const handleScroll = useCallback(() => { + const el = containerRef.current; + if (!el) return; + + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + isNearBottomRef.current = distanceFromBottom < 80; + + // 触顶加载判定 (scrollTop < 120 且还有更早历史且当前未在加载中) + if (el.scrollTop < 120 && hasMoreBefore && !isLoadingOlder && onLoadOlder) { + onLoadOlder(el); + } + }, [hasMoreBefore, isLoadingOlder, onLoadOlder]); + + // 首屏挂载 / 切换会话时瞬时落底 (无动画、无滑动闪烁) + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) return; + + if (isInitialMountRef.current && totalMessagesCount > 0) { + el.scrollTop = el.scrollHeight; + isInitialMountRef.current = false; + prevMsgCountRef.current = totalMessagesCount; + } + }, [totalMessagesCount]); + + // 新消息到达时的滚动逻辑 useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); + const el = containerRef.current; + if (!el) return; + + if (totalMessagesCount > prevMsgCountRef.current) { + // 仅当用户本来就靠近底部时才平滑跟随,不干扰向上翻阅历史的用户 + if (isNearBottomRef.current) { + el.scrollTo({ + top: el.scrollHeight, + behavior: 'smooth', + }); + } + prevMsgCountRef.current = totalMessagesCount; + } + }, [totalMessagesCount]); return (
- {messages.length === 0 ? ( + {/* 触顶加载指示器 */} + {isLoadingOlder && ( +
+ + 正在加载更早历史消息… +
+ )} + + {activeBuckets.length === 0 ? (
= ({ flexDirection: 'column', alignItems: 'center', justifyContent: 'center', - color: 'var(--text-faint)', + color: 'var(--text-faint, #9ca3af)', gap: '8px', fontSize: '13px', }} @@ -47,17 +148,23 @@ export const MessageStream: React.FC = ({ 暂无对话记录,请在下方发送立项需求
) : ( - messages.map((msg) => ( - + activeBuckets.map((bucket) => ( + + {bucket.dayKey && ( + + )} + {bucket.messages.map((msg) => ( + + ))} + )) )} -
); }; diff --git a/web/src/components/history/DayDivider.tsx b/web/src/components/history/DayDivider.tsx new file mode 100644 index 00000000..f3f52595 --- /dev/null +++ b/web/src/components/history/DayDivider.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Calendar, AlertCircle } from 'lucide-react'; +import { ChatDayBucket } from '../../types/workbench'; + +export interface DayDividerProps { + bucket: ChatDayBucket; + onLoadFullDay?: (dayKey: string) => void; +} + +function formatDayTitle(dayKey: string): string { + if (!dayKey) return ''; + const parts = dayKey.split('-'); + if (parts.length !== 3) return dayKey; + + const year = parts[0]; + const month = parts[1]; + const day = parts[2]; + + const now = new Date(); + const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + + const yesterday = new Date(now.getTime() - 86400000); + const yesterdayStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}`; + + let suffix = ''; + if (dayKey === todayStr) { + suffix = ' (今天)'; + } else if (dayKey === yesterdayStr) { + suffix = ' (昨天)'; + } else { + try { + const d = new Date(`${dayKey}T00:00:00`); + const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + suffix = ` (${weekDays[d.getDay()]})`; + } catch (_e) { + // ignore + } + } + + return `${year}年${month}月${day}日${suffix}`; +} + +export const DayDivider: React.FC = ({ bucket, onLoadFullDay }) => { + const title = formatDayTitle(bucket.dayKey); + + return ( +
+
+ + {title} + + {bucket.count} 条 + +
+ + {bucket.truncated && ( +
+ + 本日消息较多(共 {bucket.count} 条,已显示最新 {bucket.loadedCount} 条) + {onLoadFullDay && ( + + )} +
+ )} +
+ ); +}; diff --git a/web/src/data/storage.ts b/web/src/data/storage.ts index 5c8c7b57..8b46948b 100644 --- a/web/src/data/storage.ts +++ b/web/src/data/storage.ts @@ -1,4 +1,4 @@ -import { ProjectSession, ChatMessage, DeliverableItem } from '../types/workbench'; +import { ProjectSession, ChatMessage, DeliverableItem, ChatDayBucket } from '../types/workbench'; import { RoleDefinition } from '../types/rbac'; import { UserProfile } from '../types/docgov'; import { buildPrivateSessionId, dedupePrivateSessions } from './sessionPrivacy'; @@ -507,6 +507,84 @@ export function saveMessages(messages: ChatMessage[]): void { safeSetItem(STORAGE_KEYS.MESSAGES, messages); } +// ================================================================================== +// P0: 会话窗口 LRU 缓存与输入草稿持久化 (替代原全量 MESSAGES 持久化) +// ================================================================================== +const WINDOW_CACHE_PREFIX = 'smdf_chat_window_'; +const DRAFT_CACHE_PREFIX = 'smdf_chat_draft_'; +const WINDOW_LRU_KEY = 'smdf_chat_window_lru_sessions'; +const MAX_CACHED_WINDOWS = 3; + +function getWindowCacheKey(sessionId: string): string { + return `${WINDOW_CACHE_PREFIX}${sessionId}`; +} + +function getDraftCacheKey(sessionId: string): string { + return `${DRAFT_CACHE_PREFIX}${sessionId}`; +} + +function updateWindowLRU(sessionId: string): void { + const current = safeGetItem(WINDOW_LRU_KEY, []); + const updated = [sessionId, ...current.filter((id) => id !== sessionId)]; + if (updated.length > MAX_CACHED_WINDOWS) { + const evicted = updated.slice(MAX_CACHED_WINDOWS); + evicted.forEach((id) => { + if (typeof window !== 'undefined' && window.localStorage) { + window.localStorage.removeItem(getWindowCacheKey(id)); + } + }); + } + safeSetItem(WINDOW_LRU_KEY, updated.slice(0, MAX_CACHED_WINDOWS)); +} + +export function loadSessionWindowCache(sessionId: string): ChatDayBucket[] | null { + return safeGetItem(getWindowCacheKey(sessionId), null); +} + +export function saveSessionWindowCache(sessionId: string, buckets: ChatDayBucket[]): void { + safeSetItem(getWindowCacheKey(sessionId), buckets); + updateWindowLRU(sessionId); +} + +export function loadSessionDraft(sessionId: string): string { + if (typeof window === 'undefined' || !window.localStorage) return ''; + return window.localStorage.getItem(getDraftCacheKey(sessionId)) || ''; +} + +export function saveSessionDraft(sessionId: string, draft: string): void { + if (typeof window === 'undefined' || !window.localStorage) return; + if (!draft.trim()) { + window.localStorage.removeItem(getDraftCacheKey(sessionId)); + } else { + window.localStorage.setItem(getDraftCacheKey(sessionId), draft); + } +} + +/** + * migrateAndClearLegacyMessages: 检测并清除旧版全量 MESSAGES 键,释放 5MB 配额 + */ +export async function migrateAndClearLegacyMessages(): Promise { + if (typeof window === 'undefined' || !window.localStorage) return; + const raw = window.localStorage.getItem(STORAGE_KEYS.MESSAGES); + if (!raw) return; + + try { + const parsed: ChatMessage[] = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + // 尝试在 dev 环境下调用导入接口做一次性回填 + await fetch('/api/v1/chat/messages/import', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(parsed), + }).catch(() => {}); + } + } catch (_e) { + // 静默兜底 + } finally { + window.localStorage.removeItem(STORAGE_KEYS.MESSAGES); + } +} + export function loadDeliverables(fallback: DeliverableItem[]): DeliverableItem[] { const loaded = safeGetItem(STORAGE_KEYS.DELIVERABLES, fallback); const existingDocIds = new Set(loaded.map((d) => d.docId)); diff --git a/web/src/hooks/useChatWindow.test.ts b/web/src/hooks/useChatWindow.test.ts new file mode 100644 index 00000000..97e9df07 --- /dev/null +++ b/web/src/hooks/useChatWindow.test.ts @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mapRecordToMessage, mapRawBucket } from '../api/chatWindow'; + +test('mapRecordToMessage: 正确映射服务端消息记录为前端 ChatMessage', () => { + const raw = { + id: 1024, + session_id: 'sess_test_001', + user_id: 101, + role: 'user' as const, + content: '立项便携超声设备', + intent: 'SUPPLEMENT_INFO', + branch: 'EXPECTATION_HIT', + metadata: { + author_name: '张工', + attachments: [{ name: 'spec.pdf', size: '2MB', type: 'pdf' }], + }, + day_key: '2026-09-10', + category: 'consult', + created_at: '2026-09-10T09:12:00+08:00', + }; + + const msg = mapRecordToMessage(raw); + + assert.equal(msg.id, 'msg-1024'); + assert.equal(msg.serverId, 1024); + assert.equal(msg.sessionId, 'sess_test_001'); + assert.equal(msg.role, 'user'); + assert.equal(msg.authorName, '张工'); + assert.equal(msg.content, '立项便携超声设备'); + assert.equal(msg.intentTag, 'SUPPLEMENT_INFO'); + assert.equal(msg.branchTag, 'EXPECTATION_HIT'); + assert.equal(msg.dayKey, '2026-09-10'); + assert.equal(msg.category, 'consult'); + assert.equal(msg.attachments?.length, 1); +}); + +test('mapRawBucket: 正确映射服务端按日分片桶', () => { + const rawBucket = { + day_key: '2026-09-10', + count: 38, + loaded_count: 38, + truncated: false, + first_message_id: 1024, + last_message_id: 1061, + messages: [ + { + id: 1024, + session_id: 'sess_test_001', + user_id: 101, + role: 'user' as const, + content: '立项需求', + day_key: '2026-09-10', + created_at: '2026-09-10T09:12:00+08:00', + }, + ], + }; + + const bucket = mapRawBucket(rawBucket); + + assert.equal(bucket.dayKey, '2026-09-10'); + assert.equal(bucket.count, 38); + assert.equal(bucket.loadedCount, 38); + assert.equal(bucket.truncated, false); + assert.equal(bucket.firstMessageId, 1024); + assert.equal(bucket.lastMessageId, 1061); + assert.equal(bucket.messages.length, 1); + assert.equal(bucket.messages[0].serverId, 1024); +}); diff --git a/web/src/hooks/useChatWindow.ts b/web/src/hooks/useChatWindow.ts new file mode 100644 index 00000000..c70da942 --- /dev/null +++ b/web/src/hooks/useChatWindow.ts @@ -0,0 +1,440 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { ChatMessage, ChatDayBucket } from '../types/workbench'; +import { + fetchChatWindow, + fetchDayMessages, +} from '../api/chatWindow'; +import { + loadSessionWindowCache, + saveSessionWindowCache, + loadSessionDraft, + saveSessionDraft, +} from '../data/storage'; + +export type WindowPhase = + | 'loading_window' + | 'ready' + | 'loading_older' + | 'error'; + +export interface UseChatWindowOptions { + sessionId: string; + days?: number; + initialFallbackMessages?: ChatMessage[]; + liveMessages?: ChatMessage[]; +} + +export interface UseChatWindowReturn { + phase: WindowPhase; + dayStack: ChatDayBucket[]; + displayDayStack: ChatDayBucket[]; + allMessages: ChatMessage[]; + hasMoreBefore: boolean; + loadOlder: (container: HTMLElement | null) => Promise; + loadFullDay: (dayKey: string) => Promise; + appendLiveMessage: (msg: ChatMessage) => void; + updateLiveMessage: (id: string, patch: Partial) => void; + replaceMessageServerId: (tempId: string, serverId: number) => void; + draft: string; + setDraft: (draft: string) => void; + refresh: () => Promise; +} + +const MAX_WINDOW_DAYS_STACK = 31; +const MAX_WINDOW_MSGS_STACK = 3000; + +function getTodayKey(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; +} + +export function useChatWindow({ + sessionId, + days = 7, + initialFallbackMessages, + liveMessages, +}: UseChatWindowOptions): UseChatWindowReturn { + const [phase, setPhase] = useState('loading_window'); + const [dayStack, setDayStack] = useState(() => { + const cached = loadSessionWindowCache(sessionId); + if (cached && cached.length > 0) return cached; + return []; + }); + const [hasMoreBefore, setHasMoreBefore] = useState(true); + const [oldestLoadedDay, setOldestLoadedDay] = useState(''); + + const [draft, setDraftState] = useState(() => loadSessionDraft(sessionId)); + + const setDraft = useCallback( + (newDraft: string) => { + setDraftState(newDraft); + saveSessionDraft(sessionId, newDraft); + }, + [sessionId] + ); + + // 滚动补偿 pending 标记 + const scrollCompensationRef = useRef<{ + container: HTMLElement; + prevHeight: number; + prevTop: number; + } | null>(null); + + // 执行滚动补偿 + useEffect(() => { + if (scrollCompensationRef.current) { + const { container, prevHeight, prevTop } = scrollCompensationRef.current; + const heightDelta = container.scrollHeight - prevHeight; + if (heightDelta > 0) { + container.scrollTop = prevTop + heightDelta; + } + scrollCompensationRef.current = null; + } + }, [dayStack]); + + // 初次拉取窗口 + const loadInitialWindow = useCallback(async () => { + if (!sessionId) return; + setPhase('loading_window'); + + try { + const res = await fetchChatWindow(sessionId, days); + if (res.days.length > 0) { + setDayStack(res.days); + setHasMoreBefore(res.hasMoreBefore); + setOldestLoadedDay(res.oldestLoadedDay); + saveSessionWindowCache(sessionId, res.days); + } else if (initialFallbackMessages && initialFallbackMessages.length > 0) { + // 若服务端尚无历史数据(如纯本地 Demo 会话),将 fallback 消息包装为今日 bucket + const todayStr = getTodayKey(); + const fallbackBucket: ChatDayBucket = { + dayKey: todayStr, + count: initialFallbackMessages.length, + loadedCount: initialFallbackMessages.length, + truncated: false, + firstMessageId: 0, + lastMessageId: 0, + messages: initialFallbackMessages, + }; + setDayStack([fallbackBucket]); + setHasMoreBefore(false); + } else { + setDayStack([]); + setHasMoreBefore(false); + } + setPhase('ready'); + } catch (err) { + console.warn('[useChatWindow] fetch window failed, falling back to local cache/fallback', err); + // 若已有本地快照则维持 + const cached = loadSessionWindowCache(sessionId); + if (!cached && initialFallbackMessages && initialFallbackMessages.length > 0) { + const todayStr = getTodayKey(); + setDayStack([ + { + dayKey: todayStr, + count: initialFallbackMessages.length, + loadedCount: initialFallbackMessages.length, + truncated: false, + firstMessageId: 0, + lastMessageId: 0, + messages: initialFallbackMessages, + }, + ]); + } + setPhase('ready'); + } + }, [sessionId, days, initialFallbackMessages]); + + useEffect(() => { + loadInitialWindow(); + }, [loadInitialWindow]); + + // 触顶加载更早日 (滚动位置补偿) + const loadOlder = useCallback( + async (container: HTMLElement | null) => { + if (!hasMoreBefore || phase === 'loading_older' || !oldestLoadedDay || !sessionId) { + return; + } + + if (container) { + scrollCompensationRef.current = { + container, + prevHeight: container.scrollHeight, + prevTop: container.scrollTop, + }; + } + + setPhase('loading_older'); + + try { + const res = await fetchChatWindow(sessionId, 7, oldestLoadedDay); + if (res.days.length > 0) { + setDayStack((prev) => { + // 合并并去重 + const existingKeys = new Set(prev.map((b) => b.dayKey)); + const newBuckets = res.days.filter((b) => !existingKeys.has(b.dayKey)); + const combined = [...newBuckets, ...prev].sort((a, b) => (a.dayKey < b.dayKey ? -1 : 1)); + + // 容量保护:若超出上限,从最早日出栈 + let protectedStack = combined; + if (protectedStack.length > MAX_WINDOW_DAYS_STACK) { + protectedStack = protectedStack.slice(protectedStack.length - MAX_WINDOW_DAYS_STACK); + } + let totalMsgs = protectedStack.reduce((acc, b) => acc + b.loadedCount, 0); + while (totalMsgs > MAX_WINDOW_MSGS_STACK && protectedStack.length > 1) { + const removed = protectedStack.shift(); + if (removed) totalMsgs -= removed.loadedCount; + } + + saveSessionWindowCache(sessionId, protectedStack); + return protectedStack; + }); + + setHasMoreBefore(res.hasMoreBefore); + setOldestLoadedDay(res.oldestLoadedDay); + } else { + setHasMoreBefore(false); + } + } catch (err) { + console.error('[useChatWindow] loadOlder failed', err); + } finally { + setPhase('ready'); + } + }, + [hasMoreBefore, phase, oldestLoadedDay, sessionId] + ); + + // 加载当日全量消息 + const loadFullDay = useCallback( + async (dayKey: string) => { + if (!sessionId || !dayKey) return; + try { + const res = await fetchDayMessages(sessionId, dayKey, { limit: 300 }); + setDayStack((prev) => { + const updated = prev.map((b) => { + if (b.dayKey === dayKey) { + return { + ...b, + messages: res.messages, + loadedCount: res.messages.length, + truncated: false, + }; + } + return b; + }); + saveSessionWindowCache(sessionId, updated); + return updated; + }); + } catch (err) { + console.error('[useChatWindow] loadFullDay failed', err); + } + }, + [sessionId] + ); + + // 追加新消息 (SSE 或用户发送) + const appendLiveMessage = useCallback( + (msg: ChatMessage) => { + const todayStr = msg.dayKey || getTodayKey(); + const enrichedMsg: ChatMessage = { + ...msg, + dayKey: todayStr, + }; + + setDayStack((prev) => { + let found = false; + const updated = prev.map((bucket) => { + if (bucket.dayKey === todayStr) { + found = true; + return { + ...bucket, + count: bucket.count + 1, + loadedCount: bucket.loadedCount + 1, + messages: [...bucket.messages, enrichedMsg], + }; + } + return bucket; + }); + + if (!found) { + const newBucket: ChatDayBucket = { + dayKey: todayStr, + count: 1, + loadedCount: 1, + truncated: false, + firstMessageId: msg.serverId || 0, + lastMessageId: msg.serverId || 0, + messages: [enrichedMsg], + }; + updated.push(newBucket); + updated.sort((a, b) => (a.dayKey < b.dayKey ? -1 : 1)); + } + + saveSessionWindowCache(sessionId, updated); + return updated; + }); + }, + [sessionId] + ); + + // 原地更新指定消息 + const updateLiveMessage = useCallback( + (id: string, patch: Partial) => { + setDayStack((prev) => { + let changed = false; + const updated = prev.map((bucket) => { + const mIdx = bucket.messages.findIndex((m) => m.id === id); + if (mIdx >= 0) { + changed = true; + const copyMsgs = [...bucket.messages]; + copyMsgs[mIdx] = { ...copyMsgs[mIdx], ...patch }; + return { ...bucket, messages: copyMsgs }; + } + return bucket; + }); + if (changed) { + saveSessionWindowCache(sessionId, updated); + return updated; + } + return prev; + }); + }, + [sessionId] + ); + + // 将流式临时 ID 替换为服务端落库真实 serverId + const replaceMessageServerId = useCallback( + (tempId: string, serverId: number) => { + setDayStack((prev) => { + let changed = false; + const updated = prev.map((bucket) => { + const mIdx = bucket.messages.findIndex((m) => m.id === tempId); + if (mIdx >= 0) { + changed = true; + const copyMsgs = [...bucket.messages]; + copyMsgs[mIdx] = { + ...copyMsgs[mIdx], + serverId, + }; + return { + ...bucket, + lastMessageId: serverId, + messages: copyMsgs, + }; + } + return bucket; + }); + if (changed) { + saveSessionWindowCache(sessionId, updated); + return updated; + } + return prev; + }); + }, + [sessionId] + ); + + // 将 liveMessages (来自组件层实时状态,如刚发送的 user 消息或 streaming 中的 assistant 气泡) + // 无缝合并进对应的日 bucket 中供视图渲染 + const displayDayStack = useCallback(() => { + if (!liveMessages || liveMessages.length === 0) { + return dayStack; + } + + const todayStr = getTodayKey(); + const existingMsgIds = new Set(); + for (const b of dayStack) { + for (const m of b.messages) { + existingMsgIds.add(m.id); + if (m.serverId) existingMsgIds.add(`msg-${m.serverId}`); + } + } + + const pendingLive = liveMessages.filter((m) => !existingMsgIds.has(m.id)); + + if (dayStack.length === 0) { + return [ + { + dayKey: todayStr, + count: liveMessages.length, + loadedCount: liveMessages.length, + truncated: false, + firstMessageId: liveMessages[0].serverId || 0, + lastMessageId: liveMessages[liveMessages.length - 1].serverId || 0, + messages: liveMessages, + }, + ]; + } + + if (pendingLive.length === 0) { + const liveMap = new Map(liveMessages.map((m) => [m.id, m])); + let hasUpdate = false; + const updatedBuckets = dayStack.map((b) => { + let bucketChanged = false; + const newMsgs = b.messages.map((m) => { + const liveM = liveMap.get(m.id); + if ( + liveM && + (liveM.content !== m.content || + liveM.status !== m.status || + liveM.livePhaseLabel !== m.livePhaseLabel || + liveM.errorInfo !== m.errorInfo) + ) { + bucketChanged = true; + hasUpdate = true; + return { ...m, ...liveM }; + } + return m; + }); + if (bucketChanged) { + return { ...b, messages: newMsgs }; + } + return b; + }); + return hasUpdate ? updatedBuckets : dayStack; + } + + const result = dayStack.map((b) => ({ ...b, messages: [...b.messages] })); + let todayBucket = result.find((b) => b.dayKey === todayStr); + if (!todayBucket) { + todayBucket = { + dayKey: todayStr, + count: 0, + loadedCount: 0, + truncated: false, + firstMessageId: 0, + lastMessageId: 0, + messages: [], + }; + result.push(todayBucket); + result.sort((a, b) => (a.dayKey < b.dayKey ? -1 : 1)); + } + + for (const msg of pendingLive) { + todayBucket.messages.push(msg); + todayBucket.count++; + todayBucket.loadedCount++; + } + + return result; + }, [dayStack, liveMessages])(); + + // 扁平展开供现有下游(卡片处理、DST等)快速访问 + const allMessages = displayDayStack.flatMap((b) => b.messages); + + return { + phase, + dayStack, + displayDayStack, + allMessages, + hasMoreBefore, + loadOlder, + loadFullDay, + appendLiveMessage, + updateLiveMessage, + replaceMessageServerId, + draft, + setDraft, + refresh: loadInitialWindow, + }; +} diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index f545de33..98638057 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -132,6 +132,55 @@ export interface ChatMessage { taskRequest?: TaskRunContext; /** P2: 最近一次任务成功/收尾时的工作区目录(done.workspace_id) */ taskWorkspaceId?: string; + + /** P0: 历史归档与导航扩展 */ + serverId?: number; + dayKey?: string; + category?: MessageCategory; + quotes?: QuotedMessage[]; + createdAt?: string; +} + +export type MessageCategory = + | 'milestone' + | 'file' + | 'review' + | 'task' + | 'consult' + | 'casual' + | 'system' + | 'uncategorized'; + +export interface QuotedMessage { + messageId: number; + excerpt: string; + authorName: string; + dayKey: string; + deleted?: boolean; +} + +export interface ChatDayBucket { + dayKey: string; + count: number; + loadedCount: number; + truncated: boolean; + firstMessageId: number; + lastMessageId: number; + messages: ChatMessage[]; +} + +export interface ChatWindowResult { + sessionId: string; + days: ChatDayBucket[]; + hasMoreBefore: boolean; + oldestLoadedDay: string; +} + +export interface ChatDateStat { + dayKey: string; + count: number; + categories: Record; + digestStatus?: string; } export type DeliverableCategory = 'workflow' | 'shared' | 'private';