feat(history): 实现多级历史导航、分类筛选、日历联动与消息双向引用 (Phase P1)

- 后端核心与 API:
  - 实现 ChatCategoryStat 结构体与 ListChatCategories 统计引擎 (Postgres + In-Memory 内存降级双轨)
  - 注册 GET /api/v1/chat/categories 聚合接口
  - 增强 GetChatDayMessages 支持自动根据 anchor_id 逆查 day_key,实现免猜测跨日检索
- 前端锚点导航引擎:
  - 实现 anchorNavigator 模块 (快路径 DOM scrollIntoView + 1.8s 发光脉冲、慢路径跨日拉取、深链 Hash 解析)
  - 编写 anchorNavigator.test.ts 单元测试并 100% 通过
- 分类过滤与轻量月历:
  - 实现 CategoryFilterBar 水平分类筛选栏 (8 类业务标签,暗淡/高亮模式)
  - 实现 HistoryCalendar 零依赖原生悬浮月历组件 (热度点与消息数,jumpToDate 快速定位)
- 消息引用与双向追溯:
  - 消息气泡悬浮“引用”入口,点击提取发言人与摘要至输入框
  - 实现 QuoteChip 待发引用标签与 QuoteCard 已发引用卡片
  - 引用卡片点击直连锚点定位,快慢路径自动回溯
This commit is contained in:
WeiCHEN
2026-09-10 13:40:44 +08:00
parent 2ece0ce882
commit ad57685665
22 changed files with 1487 additions and 15 deletions
+22
View File
@@ -72,3 +72,25 @@ func DeriveMessageCategory(role, intent, branch string, meta map[string]any) str
return CategoryUncategorized
}
// GetCategoryLabel 返回分类的标准展示中文标签
func GetCategoryLabel(cat string) string {
switch cat {
case CategoryMilestone:
return "里程碑"
case CategoryReview:
return "审批审查"
case CategoryTask:
return "任务驱动"
case CategoryFile:
return "文档交付"
case CategoryConsult:
return "业务咨询"
case CategoryCasual:
return "日常协同"
case CategorySystem:
return "系统通知"
default:
return "未分类"
}
}
+124 -2
View File
@@ -279,8 +279,40 @@ func (m *ContextManager) getChatWindowMem(ctx context.Context, q ChatWindowQuery
// 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 sessionID == "" {
return nil, errors.New("session_id required")
}
if dayKey == "" {
if anchorID <= 0 {
return nil, errors.New("date or anchor_id required")
}
if m.pool != nil {
var resolvedDay string
err := m.pool.QueryRow(ctx, `
SELECT COALESCE(day_key::text, '')
FROM chat_messages
WHERE id = $1 AND session_id = $2 AND deleted_at IS NULL
`, anchorID, sessionID).Scan(&resolvedDay)
if err != nil || resolvedDay == "" {
return nil, fmt.Errorf("message with anchor_id %d not found in session", anchorID)
}
dayKey = resolvedDay
} else {
m.mu.RLock()
for _, msg := range m.messages[sessionID] {
if msg.ID == anchorID && msg.DeletedAt == nil {
dayKey = msg.DayKey
if dayKey == "" {
dayKey = msg.CreatedAt.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02")
}
break
}
}
m.mu.RUnlock()
if dayKey == "" {
return nil, fmt.Errorf("message with anchor_id %d not found in session", anchorID)
}
}
}
if limit <= 0 {
limit = 100
@@ -664,3 +696,93 @@ func (m *ContextManager) ListChatDates(ctx context.Context, sessionID string) ([
}
return res, nil
}
// ListChatCategories 统计会话各分类消息数及首尾消息ID (用于分类筛选条)
func (m *ContextManager) ListChatCategories(ctx context.Context, sessionID string) ([]ChatCategoryStat, error) {
if sessionID == "" {
return nil, errors.New("session_id required")
}
if m.pool != nil {
rows, err := m.pool.Query(ctx, `
SELECT category, COUNT(*), MIN(id), MAX(id)
FROM chat_messages
WHERE session_id = $1 AND deleted_at IS NULL
GROUP BY category
ORDER BY COUNT(*) DESC
`, sessionID)
if err != nil {
return nil, fmt.Errorf("query chat categories failed: %w", err)
}
defer rows.Close()
var res []ChatCategoryStat
for rows.Next() {
var cat string
var count int
var minID, maxID int64
if err := rows.Scan(&cat, &count, &minID, &maxID); err != nil {
return nil, fmt.Errorf("scan chat category stat failed: %w", err)
}
if cat == "" {
cat = CategoryUncategorized
}
res = append(res, ChatCategoryStat{
Category: cat,
Label: GetCategoryLabel(cat),
Count: count,
FirstMessageID: minID,
LastMessageID: maxID,
})
}
return res, nil
}
m.mu.RLock()
defer m.mu.RUnlock()
allMsgs := m.messages[sessionID]
statMap := make(map[string]*ChatCategoryStat)
for _, msg := range allMsgs {
if msg.DeletedAt != nil {
continue
}
cat := msg.Category
if cat == "" {
cat = CategoryUncategorized
}
st, ok := statMap[cat]
if !ok {
st = &ChatCategoryStat{
Category: cat,
Label: GetCategoryLabel(cat),
Count: 0,
FirstMessageID: msg.ID,
LastMessageID: msg.ID,
}
statMap[cat] = st
}
st.Count++
if msg.ID < st.FirstMessageID {
st.FirstMessageID = msg.ID
}
if msg.ID > st.LastMessageID {
st.LastMessageID = msg.ID
}
}
res := make([]ChatCategoryStat, 0, len(statMap))
for _, st := range statMap {
res = append(res, *st)
}
sort.Slice(res, func(i, j int) bool {
if res[i].Count != res[j].Count {
return res[i].Count > res[j].Count
}
return res[i].Category < res[j].Category
})
return res, nil
}
+43
View File
@@ -280,6 +280,14 @@ func TestChatWindow_DatabaseIntegration(t *testing.T) {
t.Fatalf("RecordUserMessageWithID d2 failed: %v", err)
}
_, err = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "启动注册申报任务", "TRIGGER_TASK", "", map[string]any{
"day_key": d2Key,
"created_at": d2.Add(time.Minute),
})
if err != nil {
t.Fatalf("RecordUserMessageWithID d2 task failed: %v", err)
}
// 1. 测试真实 DB 的 GetChatWindow
winRes, err := mgr.GetChatWindow(ctx, ctxmgr.ChatWindowQuery{
SessionID: sessID,
@@ -315,4 +323,39 @@ func TestChatWindow_DatabaseIntegration(t *testing.T) {
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)
}
// 4. 测试真实 DB 的 ListChatCategories
catStats, err := mgr.ListChatCategories(ctx, sessID)
if err != nil {
t.Fatalf("ListChatCategories failed: %v", err)
}
if len(catStats) < 2 {
t.Fatalf("expected at least 2 categories, got %d", len(catStats))
}
}
func TestListChatCategories_MemoryMode(t *testing.T) {
mgr := ctxmgr.NewContextManager(nil, nil, ctxmgr.DefaultTimeDecayConfig())
ctx := context.Background()
sessID := "sess_mem_cat_test"
// 注入不同分类的消息
_, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "阶段推进", "ADVANCE_STAGE", "", nil) // task
_, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "启动任务", "TRIGGER_TASK", "", nil) // task
_, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "合规咨询", "QUERY_REGULATIONS", "", nil) // consult
_, _ = mgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "立项里程碑", "", "", map[string]any{"milestone": true}) // milestone
stats, err := mgr.ListChatCategories(ctx, sessID)
if err != nil {
t.Fatalf("ListChatCategories failed: %v", err)
}
if len(stats) != 3 {
t.Fatalf("expected 3 distinct categories, got %d", len(stats))
}
// 按 count DESC 排序,task 应排在首位 (count=2)
if stats[0].Category != "task" || stats[0].Count != 2 || stats[0].Label != "任务驱动" {
t.Errorf("expected first category to be task (count=2), got %+v", stats[0])
}
}
+9
View File
@@ -158,6 +158,15 @@ type ChatDateStat struct {
DigestStatus string `json:"digest_status,omitempty"` // 预留
}
// ChatCategoryStat 会话消息分类统计 (用于分类筛选条)
type ChatCategoryStat struct {
Category string `json:"category"`
Label string `json:"label"`
Count int `json:"count"`
FirstMessageID int64 `json:"first_message_id"`
LastMessageID int64 `json:"last_message_id"`
}
// BlendedSquadContext 智能体小队混合上下文 (用于注入到 Agent Prompt 中)
type BlendedSquadContext struct {
ProjectName string `json:"project_name"`
+28 -4
View File
@@ -60,14 +60,15 @@ func (s *Server) handleGetChatDayMessages(w http.ResponseWriter, r *http.Request
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)
if date == "" && anchorID <= 0 {
http.Error(w, "missing date/day_key or anchor_id query param", http.StatusBadRequest)
return
}
beforeIDStr := r.URL.Query().Get("before_id")
beforeID, _ := strconv.ParseInt(beforeIDStr, 10, 64)
@@ -112,6 +113,29 @@ func (s *Server) handleListChatDates(w http.ResponseWriter, r *http.Request) {
writeJSONResponse(w, http.StatusOK, stats)
}
// handleListChatCategories 列出会话各分类消息统计 (用于分类筛选条)
// GET /api/v1/chat/categories?session_id=
func (s *Server) handleListChatCategories(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().ListChatCategories(r.Context(), sessionID)
if err != nil {
http.Error(w, "list chat categories 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) {
+16
View File
@@ -136,6 +136,22 @@ func TestServer_ChatWindowAndDayAPIs(t *testing.T) {
if dayRes.DayKey != "2026-09-08" || dayRes.TotalCount != 2 || len(dayRes.Messages) != 2 {
t.Fatalf("unexpected day messages result: %+v", dayRes)
}
// 5. 测试 GET /api/v1/chat/categories
reqCat := httptest.NewRequest("GET", "/api/v1/chat/categories?session_id="+sessID, nil)
rrCat := httptest.NewRecorder()
handler.ServeHTTP(rrCat, reqCat)
if rrCat.Code != http.StatusOK {
t.Fatalf("GET /api/v1/chat/categories expected 200, got %d: %s", rrCat.Code, rrCat.Body.String())
}
var catStats []ctxmgr.ChatCategoryStat
if err := json.Unmarshal(rrCat.Body.Bytes(), &catStats); err != nil {
t.Fatalf("unmarshal ChatCategoryStat failed: %v", err)
}
if len(catStats) < 2 {
t.Fatalf("expected at least 2 categories, got %d", len(catStats))
}
}
func TestServer_ChatWindow_ImportGuard(t *testing.T) {
+1
View File
@@ -97,6 +97,7 @@ func (s *Server) Routes() http.Handler {
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("GET /api/v1/chat/categories", s.handleListChatCategories)
mux.HandleFunc("POST /api/v1/chat/messages/import", s.handleImportChatMessages)
mux.HandleFunc("GET /api/v1/context/cases/recommend", s.handleRecommendCases)
+134 -4
View File
@@ -79,6 +79,11 @@ import {
migrateAndClearLegacyMessages,
} from './data/storage';
import { useChatWindow } from './hooks/useChatWindow';
import { CategoryFilterBar } from './components/history/CategoryFilterBar';
import { HistoryCalendar } from './components/history/HistoryCalendar';
import { navigateToAnchor, parseAnchorHash } from './engine/anchorNavigator';
import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
export const INITIAL_DELIVERABLES: DeliverableItem[] = [
{
@@ -748,6 +753,91 @@ export default function App() {
liveMessages: currentSessionMessages,
});
// P1: 多维度历史导航与分类统计、日历、引用状态
const [categoryStats, setCategoryStats] = useState<ChatCategoryStat[]>([]);
const [dateStats, setDateStats] = useState<ChatDateStat[]>([]);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [activeQuotes, setActiveQuotes] = useState<QuotedMessage[]>([]);
// 会话切换时加载分类与日期统计
useEffect(() => {
if (!activeSessionId) return;
fetchChatCategories(activeSessionId)
.then((cats) => {
setCategoryStats(cats || []);
})
.catch((err) => {
console.warn('Failed to load category stats:', err);
});
fetchChatDates(activeSessionId)
.then((dates) => {
setDateStats(dates || []);
})
.catch((err) => {
console.warn('Failed to load date stats:', err);
});
}, [activeSessionId]);
// 监听 URL 哈希 (#/s/{sessionId}/m/{messageId}) 实现深链接直接定位与脉冲动效
useEffect(() => {
const handleHashChange = () => {
const parsed = parseAnchorHash(window.location.hash);
if (!parsed) return;
if (parsed.sessionId && parsed.sessionId !== activeSessionId) {
setActiveSessionId(parsed.sessionId);
setTimeout(() => {
navigateToAnchor(parsed, {
sessionId: parsed.sessionId,
loadDayForAnchor: chatWindow.loadDayForAnchor,
});
}, 300);
} else {
navigateToAnchor(parsed, {
sessionId: activeSessionId,
loadDayForAnchor: chatWindow.loadDayForAnchor,
});
}
};
window.addEventListener('hashchange', handleHashChange);
if (window.location.hash) {
handleHashChange();
}
return () => window.removeEventListener('hashchange', handleHashChange);
}, [activeSessionId, chatWindow.loadDayForAnchor]);
// 引用消息交互
const handleQuoteMessage = (message: ChatMessage) => {
let msgId = message.serverId;
if (!msgId && typeof message.id === 'string' && message.id.startsWith('msg-')) {
const parsedId = parseInt(message.id.slice(4), 10);
if (!isNaN(parsedId) && parsedId > 0) {
msgId = parsedId;
}
}
if (!msgId) {
msgId = Date.now();
}
const currentDay = (message as any).dayKey || new Date().toISOString().substring(0, 10);
setActiveQuotes((prev) => {
if (prev.some((q) => q.messageId === msgId)) return prev;
return [
...prev,
{
messageId: msgId!,
authorName: message.authorName || (message.role === 'user' ? '用户' : '项目助手'),
excerpt: message.content ? message.content.slice(0, 150) : '',
dayKey: currentDay,
},
];
});
};
const handleRemoveQuote = (index: number) => {
setActiveQuotes((prev) => prev.filter((_, i) => i !== index));
};
// 挂载时检测并迁移旧版 localStorage 巨大 MESSAGES 键
useEffect(() => {
migrateAndClearLegacyMessages();
@@ -962,7 +1052,12 @@ export default function App() {
// 驱动一次真实后端 SSE 流式对话尝试:气泡状态机 pending → streaming_status → completed/failed/cancelled,
// 瞬态错误在 attemptIndex < 2 时自动重试 (1s/2s),其余情况落地为结构化失败态 (P1:展示动作仅保留“重试”)
const runStreamAttempt = (userText: string, asstId: string, attemptIndex: number) => {
const runStreamAttempt = (
userText: string,
asstId: string,
attemptIndex: number,
quote?: QuotedMessage
) => {
// 同一时刻只允许一个活跃流:新尝试前中止上一个 (若存在)
streamAbortRef.current?.abort();
const controller = new AbortController();
@@ -983,7 +1078,7 @@ export default function App() {
setIsThinking(false);
return;
}
runStreamAttempt(userText, asstId, attemptIndex + 1);
runStreamAttempt(userText, asstId, attemptIndex + 1, quote);
}, delayMs);
};
@@ -1023,6 +1118,7 @@ export default function App() {
user_name: currentUser.name,
user_role: currentDocRole,
message: userText,
quote: quote,
},
{
onEvent: (ev) => {
@@ -1244,7 +1340,11 @@ export default function App() {
};
// 发送消息与对话驱动双轨逻辑
const handleSendMessage = (text: string, attachments?: { name: string; size: string; type: string }[]) => {
const handleSendMessage = (
text: string,
attachments?: { name: string; size: string; type: string }[],
quotes?: QuotedMessage[]
) => {
const trimmed = text.trim();
const userMsg: ChatMessage = {
id: 'msg-' + Date.now(),
@@ -1254,7 +1354,9 @@ export default function App() {
content: text,
timestamp: new Date().toTimeString().substring(0, 5),
attachments,
quotes: quotes && quotes.length > 0 ? quotes : undefined,
};
setActiveQuotes([]);
// 检查是否为肯定回复(双轨同效判定:打字回复等同于点击卡片默认动作)
const isAffirmative = isAffirmativeResponse(trimmed);
@@ -1356,7 +1458,7 @@ export default function App() {
sourceUserMessageId: userMsg.id,
};
setMessages((prev) => [...prev, placeholder]);
runStreamAttempt(text, asstId, 0);
runStreamAttempt(text, asstId, 0, quotes && quotes.length > 0 ? quotes[0] : undefined);
return;
}
@@ -1807,6 +1909,24 @@ export default function App() {
onOpenAdminSettings={() => setAdminSettingsOpen(true)}
chatContent={
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* P1: 顶部分类统计过滤栏与历史日历入口 */}
<CategoryFilterBar
categories={categoryStats}
selectedCategory={chatWindow.selectedCategory}
onSelectCategory={chatWindow.setSelectedCategory}
onToggleCalendar={() => setIsCalendarOpen((prev) => !prev)}
isCalendarOpen={isCalendarOpen}
/>
{isCalendarOpen && (
<HistoryCalendar
dateStats={dateStats}
onSelectDate={(date) => {
chatWindow.jumpToDate(date);
setIsCalendarOpen(false);
}}
onClose={() => setIsCalendarOpen(false)}
/>
)}
<MessageStream
dayStack={chatWindow.displayDayStack}
isLoadingOlder={chatWindow.phase === 'loading_older'}
@@ -1816,6 +1936,14 @@ export default function App() {
onOpenCase={handleSelectDoc}
onCardAction={handleCardAction}
onErrorAction={handleErrorAction}
onQuote={handleQuoteMessage}
onNavigateToAnchor={(targetAnchor) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: targetAnchor },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
}}
selectedCategory={chatWindow.selectedCategory}
/>
{pendingProposal && (
<div style={{ padding: '0 16px' }}>
@@ -1833,6 +1961,8 @@ export default function App() {
isThinking={isThinking}
isStreaming={isStreamingActive}
onStopStreaming={handleStopStreaming}
quotes={activeQuotes}
onRemoveQuote={handleRemoveQuote}
/>
</div>
}
+16
View File
@@ -3,6 +3,7 @@ import {
ChatDayBucket,
ChatWindowResult,
ChatDateStat,
ChatCategoryStat,
} from '../types/workbench';
export interface RawChatMessageRecord {
@@ -172,3 +173,18 @@ export async function importChatMessages(
}
return res.json();
}
export async function fetchChatCategories(sessionId: string): Promise<ChatCategoryStat[]> {
const res = await fetch(`/api/v1/chat/categories?session_id=${encodeURIComponent(sessionId)}`);
if (!res.ok) {
throw new Error(`fetchChatCategories failed: HTTP ${res.status}`);
}
const raw = await res.json();
return (raw || []).map((item: any) => ({
category: item.category,
label: item.label,
count: item.count || 0,
first_message_id: item.first_message_id,
last_message_id: item.last_message_id,
}));
}
+21 -2
View File
@@ -1,11 +1,19 @@
import React, { useState, useRef, useEffect } from 'react';
import { Send, Paperclip, Square, X, Loader2, Sparkles } from 'lucide-react';
import { QuotedMessage } from '../../types/workbench';
import { QuoteChip } from './QuoteChip';
interface InputAreaProps {
onSendMessage: (text: string, attachments?: { name: string; size: string; type: string }[]) => void;
onSendMessage: (
text: string,
attachments?: { name: string; size: string; type: string }[],
quotes?: QuotedMessage[]
) => void;
isThinking: boolean;
isStreaming: boolean;
onStopStreaming?: () => void;
quotes?: QuotedMessage[];
onRemoveQuote?: (index: number) => void;
}
export const InputArea: React.FC<InputAreaProps> = ({
@@ -13,6 +21,8 @@ export const InputArea: React.FC<InputAreaProps> = ({
isThinking,
isStreaming,
onStopStreaming,
quotes = [],
onRemoveQuote,
}) => {
const [text, setText] = useState('');
const [attachments, setAttachments] = useState<{ name: string; size: string; type: string }[]>([]);
@@ -39,7 +49,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
return;
}
onSendMessage(text.trim(), attachments);
onSendMessage(text.trim(), attachments, quotes);
setText('');
setAttachments([]);
if (textareaRef.current) {
@@ -95,6 +105,15 @@ export const InputArea: React.FC<InputAreaProps> = ({
accept=".pdf,.doc,.docx,.md,.txt,.xlsx"
/>
{/* 已挂载引用卡片 */}
{quotes.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{quotes.map((q, i) => (
<QuoteChip key={i} quote={q} onRemove={() => onRemoveQuote?.(i)} />
))}
</div>
)}
{/* 已挂载文件芯片 */}
{attachments.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
+53 -1
View File
@@ -3,8 +3,9 @@ import { ChatErrorInfo, ChatMessage } from '../../types/workbench';
import { PhaseEventStrip } from './PhaseEventStrip';
import { RecommendedCaseCard } from './RecommendedCaseCard';
import { InteractiveCard } from './InteractiveCard';
import { FileText, Bot, User, Loader2, AlertTriangle, Copy, Check } from 'lucide-react';
import { FileText, Bot, User, Loader2, AlertTriangle, Copy, Check, Quote as QuoteIcon } from 'lucide-react';
import { renderInlineMarkdown } from '../../engine/renderMarkdown';
import { QuoteCard } from './QuoteCard';
export interface MessageBubbleProps {
message: ChatMessage;
@@ -12,6 +13,9 @@ export interface MessageBubbleProps {
onCardAction?: (cardId: string, actionId: string, payload?: any) => void;
/** 结构化错误 / 已停止会话上的手动恢复动作:重试 / 继续任务 / 重新开始 / 重新发送 */
onErrorAction?: (messageId: string, action: 'retry' | 'continue' | 'restart') => void;
onQuote?: (message: ChatMessage) => void;
onNavigateToAnchor?: (messageId: number) => void;
isCategoryDimmed?: boolean;
}
const ERROR_ACTION_LABEL: Record<'retry' | 'continue' | 'restart', string> = {
@@ -32,6 +36,9 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
onOpenCase,
onCardAction,
onErrorAction,
onQuote,
onNavigateToAnchor,
isCategoryDimmed = false,
}) => {
const isUser = message.role === 'user';
const status = message.status;
@@ -41,6 +48,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
const isCancelled = !isUser && status === 'cancelled';
const hasContent = !!message.content && message.content.trim().length > 0;
const [diagCopied, setDiagCopied] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const renderedHtml = useMemo(
() => renderInlineMarkdown(message.content || '', { preserveNewlines: true }),
@@ -59,12 +67,16 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
id={message.serverId ? `chat-anchor-${message.serverId}` : (message.id ? `chat-anchor-${message.id}` : undefined)}
data-server-id={message.serverId}
data-category={message.category}
className={isCategoryDimmed ? 'chat-category-dimmed' : undefined}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
marginBottom: '16px',
padding: '0 16px',
transition: 'opacity 0.2s ease',
}}
>
<div
@@ -125,6 +137,34 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
{message.intentTag}
</span>
)}
{isHovered && onQuote && message.serverId && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onQuote(message);
}}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
padding: '1px 5px',
borderRadius: '4px',
fontSize: '10px',
fontWeight: 500,
background: 'rgba(79, 107, 255, 0.12)',
color: 'var(--brand-400, #6b84ff)',
border: '1px solid rgba(79, 107, 255, 0.25)',
cursor: 'pointer',
transition: 'all 0.15s ease',
marginLeft: '4px',
}}
title="引用此消息至输入框"
>
<QuoteIcon size={10} />
<span>引用</span>
</button>
)}
</div>
{/* 附件展示 */}
@@ -171,6 +211,18 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
overflowX: 'auto',
}}
>
{/* 引用依据卡片 */}
{message.quotes && message.quotes.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', width: '100%', marginBottom: '8px' }}>
{message.quotes.map((q, idx) => (
<QuoteCard
key={idx}
quote={q}
onClick={() => onNavigateToAnchor?.(q.messageId)}
/>
))}
</div>
)}
{isFailed && message.errorInfo ? (
/* 失败态:白话标题 + 一行诊断 + 复制诊断 + 分型恢复动作 */
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
+12 -1
View File
@@ -1,5 +1,5 @@
import React, { useRef, useLayoutEffect, useEffect, useCallback } from 'react';
import { ChatMessage, ChatDayBucket } from '../../types/workbench';
import { ChatMessage, ChatDayBucket, MessageCategory } from '../../types/workbench';
import { MessageBubble } from './MessageBubble';
import { DayDivider } from '../history/DayDivider';
import { Loader2 } from 'lucide-react';
@@ -14,6 +14,9 @@ export interface MessageStreamProps {
onOpenCase?: (docId: string) => void;
onCardAction?: (cardId: string, actionId: string, payload?: any) => void;
onErrorAction?: (messageId: string, action: 'retry' | 'continue' | 'restart') => void;
onQuote?: (message: ChatMessage) => void;
onNavigateToAnchor?: (messageId: number) => void;
selectedCategory?: MessageCategory | 'all';
}
export const MessageStream: React.FC<MessageStreamProps> = ({
@@ -26,6 +29,9 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
onOpenCase,
onCardAction,
onErrorAction,
onQuote,
onNavigateToAnchor,
selectedCategory = 'all',
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const isInitialMountRef = useRef<boolean>(true);
@@ -160,6 +166,11 @@ export const MessageStream: React.FC<MessageStreamProps> = ({
onOpenCase={onOpenCase}
onCardAction={onCardAction}
onErrorAction={onErrorAction}
onQuote={onQuote}
onNavigateToAnchor={onNavigateToAnchor}
isCategoryDimmed={
selectedCategory !== 'all' && msg.category !== selectedCategory
}
/>
))}
</React.Fragment>
+98
View File
@@ -0,0 +1,98 @@
import React from 'react';
import { Quote, ExternalLink } from 'lucide-react';
import { QuotedMessage } from '../../types/workbench';
interface QuoteCardProps {
quote: QuotedMessage;
onClick?: () => void;
}
export const QuoteCard: React.FC<QuoteCardProps> = ({ quote, onClick }) => {
const isDeleted = Boolean(quote.deleted);
return (
<div
onClick={isDeleted ? undefined : onClick}
style={{
display: 'flex',
flexDirection: 'column',
gap: '2px',
padding: '6px 10px',
marginBottom: '6px',
background: 'rgba(0, 0, 0, 0.2)',
borderLeft: isDeleted
? '3px solid var(--text-faint, #64748b)'
: '3px solid var(--brand-500, #4f6bff)',
borderRadius: '0 var(--r-md, 6px) var(--r-md, 6px) 0',
cursor: isDeleted ? 'default' : 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
opacity: isDeleted ? 0.6 : 1,
}}
title={isDeleted ? '原消息已删除' : `点击跳转至 ${quote.authorName} 的原始消息`}
onMouseEnter={(e) => {
if (!isDeleted && onClick) {
e.currentTarget.style.background = 'rgba(79, 107, 255, 0.12)';
}
}}
onMouseLeave={(e) => {
if (!isDeleted) {
e.currentTarget.style.background = 'rgba(0, 0, 0, 0.2)';
}
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontSize: '11px',
fontWeight: 600,
color: isDeleted ? 'var(--text-faint, #64748b)' : 'var(--brand-400, #6b84ff)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<Quote size={11} />
<span>{quote.authorName || '协同成员'}</span>
{quote.dayKey && (
<span style={{ fontSize: '10px', fontWeight: 400, opacity: 0.75 }}>
· {quote.dayKey}
</span>
)}
{isDeleted && (
<span
style={{
fontSize: '9px',
padding: '0 4px',
borderRadius: '3px',
background: 'rgba(255, 255, 255, 0.08)',
color: 'var(--text-faint, #64748b)',
marginLeft: '4px',
}}
>
已删除
</span>
)}
</div>
{!isDeleted && onClick && (
<ExternalLink size={10} color="var(--brand-400, #6b84ff)" style={{ opacity: 0.7 }} />
)}
</div>
<div
style={{
fontSize: '12px',
color: 'var(--text-muted, #94a3b8)',
lineHeight: '1.4',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{quote.excerpt}
</div>
</div>
);
};
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import { Quote, X } from 'lucide-react';
import { QuotedMessage } from '../../types/workbench';
interface QuoteChipProps {
quote: QuotedMessage;
onRemove: () => void;
}
export const QuoteChip: React.FC<QuoteChipProps> = ({ quote, onRemove }) => {
const shortDate = quote.dayKey ? quote.dayKey.substring(5) : '';
const snippet = quote.excerpt.length > 50 ? quote.excerpt.substring(0, 50) + '...' : quote.excerpt;
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '3px 8px 3px 10px',
background: 'rgba(79, 107, 255, 0.12)',
border: '1px solid rgba(79, 107, 255, 0.3)',
borderRadius: 'var(--r-md, 8px)',
fontSize: '11px',
color: 'var(--text-strong, #f8fafc)',
maxWidth: '360px',
userSelect: 'none',
}}
>
<Quote size={11} color="var(--brand-400, #6b84ff)" style={{ flexShrink: 0 }} />
<span style={{ fontWeight: 600, color: 'var(--brand-400, #6b84ff)', flexShrink: 0 }}>
{quote.authorName || '历史发言'}
{shortDate ? ` (${shortDate})` : ''}:
</span>
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: 'var(--text-muted, #94a3b8)',
}}
title={quote.excerpt}
>
{snippet}
</span>
<button
type="button"
onClick={onRemove}
style={{
padding: '2px',
borderRadius: '4px',
background: 'transparent',
color: 'var(--text-faint, #64748b)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
flexShrink: 0,
marginLeft: '2px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = 'var(--text-strong, #fff)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = 'var(--text-faint, #64748b)';
}}
title="移除引用"
>
<X size={12} />
</button>
</div>
);
};
@@ -0,0 +1,188 @@
import React from 'react';
import {
Flag,
CheckSquare,
Play,
FileText,
HelpCircle,
MessageSquare,
Calendar,
Layers,
Info,
} from 'lucide-react';
import { MessageCategory, ChatCategoryStat } from '../../types/workbench';
interface CategoryFilterBarProps {
categories: ChatCategoryStat[];
selectedCategory: MessageCategory | 'all';
onSelectCategory: (cat: MessageCategory | 'all') => void;
onToggleCalendar?: () => void;
isCalendarOpen?: boolean;
}
const CATEGORY_META: Record<
string,
{ label: string; icon: React.FC<{ size?: number; color?: string }>; color: string }
> = {
milestone: { label: '里程碑', icon: Flag, color: 'var(--amber-500, #f59e0b)' },
review: { label: '审批审查', icon: CheckSquare, color: 'var(--brand-500, #4f6bff)' },
task: { label: '任务驱动', icon: Play, color: 'var(--success, #10b981)' },
file: { label: '文档交付', icon: FileText, color: '#a855f7' },
consult: { label: '业务咨询', icon: HelpCircle, color: '#06b6d4' },
casual: { label: '日常协同', icon: MessageSquare, color: 'var(--text-muted, #94a3b8)' },
system: { label: '系统通知', icon: Info, color: 'var(--text-faint, #64748b)' },
uncategorized: { label: '未分类', icon: Layers, color: 'var(--text-faint, #64748b)' },
};
export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
categories,
selectedCategory,
onSelectCategory,
onToggleCalendar,
isCalendarOpen = false,
}) => {
const totalCount = categories.reduce((sum, c) => sum + c.count, 0);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 16px',
background: 'var(--bg-panel, #1e222d)',
borderBottom: '1px solid var(--border, #2e3444)',
gap: '8px',
overflowX: 'auto',
whiteSpace: 'nowrap',
userSelect: 'none',
flexShrink: 0,
}}
>
{/* 左侧 Chips 列表 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
overflowX: 'auto',
scrollbarWidth: 'none',
}}
>
{/* 全部按钮 */}
<button
onClick={() => onSelectCategory('all')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '5px',
padding: '3px 10px',
borderRadius: 'var(--r-pill, 9999px)',
fontSize: '11px',
fontWeight: 600,
background:
selectedCategory === 'all'
? 'var(--brand-500, #4f6bff)'
: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
color: selectedCategory === 'all' ? '#fff' : 'var(--text-muted, #94a3b8)',
border:
selectedCategory === 'all'
? '1px solid var(--brand-400, #6b84ff)'
: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
>
<span>全部</span>
{totalCount > 0 && (
<span
style={{
fontSize: '10px',
opacity: selectedCategory === 'all' ? 0.9 : 0.6,
marginLeft: '2px',
}}
>
{totalCount}
</span>
)}
</button>
{/* 分类 Chips */}
{categories.map((stat) => {
const isSelected = selectedCategory === stat.category;
const meta = CATEGORY_META[stat.category] || CATEGORY_META.uncategorized;
const Icon = meta.icon;
return (
<button
key={stat.category}
onClick={() => onSelectCategory(isSelected ? 'all' : stat.category)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '5px',
padding: '3px 10px',
borderRadius: 'var(--r-pill, 9999px)',
fontSize: '11px',
fontWeight: isSelected ? 600 : 500,
background: isSelected
? 'var(--brand-grad, linear-gradient(135deg, #4f6bff, #3350e0))'
: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
color: isSelected ? '#fff' : 'var(--text-muted, #94a3b8)',
border: isSelected
? '1px solid var(--brand-400, #6b84ff)'
: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title={`筛选【${stat.label}】分类消息`}
>
<Icon size={12} color={isSelected ? '#fff' : meta.color} />
<span>{stat.label}</span>
<span
style={{
fontSize: '10px',
padding: '1px 5px',
borderRadius: '10px',
background: isSelected ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.06)',
color: isSelected ? '#fff' : 'var(--text-faint, #64748b)',
}}
>
{stat.count}
</span>
</button>
);
})}
</div>
{/* 右侧日历入口 */}
{onToggleCalendar && (
<button
onClick={onToggleCalendar}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '5px',
padding: '3px 10px',
borderRadius: 'var(--r-pill, 9999px)',
fontSize: '11px',
fontWeight: 600,
background: isCalendarOpen
? 'rgba(79, 107, 255, 0.2)'
: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
color: isCalendarOpen ? 'var(--brand-400, #6b84ff)' : 'var(--text-muted, #94a3b8)',
border: isCalendarOpen
? '1px solid var(--brand-500, #4f6bff)'
: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title="展开历史日历"
>
<Calendar size={13} />
<span>历史日历</span>
</button>
)}
</div>
);
};
@@ -45,6 +45,7 @@ export const DayDivider: React.FC<DayDividerProps> = ({ bucket, onLoadFullDay })
return (
<div
id={bucket.dayKey ? `chat-day-divider-${bucket.dayKey}` : undefined}
style={{
display: 'flex',
flexDirection: 'column',
@@ -0,0 +1,256 @@
import React, { useState, useMemo } from 'react';
import { ChevronLeft, ChevronRight, X, Calendar as CalendarIcon } from 'lucide-react';
import { ChatDateStat } from '../../types/workbench';
interface HistoryCalendarProps {
dateStats: ChatDateStat[];
onSelectDate: (dayKey: string) => void;
onClose: () => void;
}
export const HistoryCalendar: React.FC<HistoryCalendarProps> = ({
dateStats,
onSelectDate,
onClose,
}) => {
// 提取有消息的最新年月作为初始视图
const initialYearMonth = useMemo(() => {
if (dateStats.length > 0) {
const latest = dateStats[dateStats.length - 1].dayKey;
const parts = latest.split('-');
if (parts.length >= 2) {
return {
year: parseInt(parts[0], 10),
month: parseInt(parts[1], 10),
};
}
}
const now = new Date();
return { year: now.getFullYear(), month: now.getMonth() + 1 };
}, [dateStats]);
const [currentYear, setCurrentYear] = useState<number>(initialYearMonth.year);
const [currentMonth, setCurrentMonth] = useState<number>(initialYearMonth.month);
// 映射 dayKey -> count
const statMap = useMemo(() => {
const map = new Map<string, number>();
for (const s of dateStats) {
map.set(s.dayKey, s.count);
}
return map;
}, [dateStats]);
// 计算当月日历格
const daysInMonth = new Date(currentYear, currentMonth, 0).getDate();
// 星期天为 0,转换成周一为 0
const firstDayWeek = (new Date(currentYear, currentMonth - 1, 1).getDay() + 6) % 7;
const handlePrevMonth = () => {
if (currentMonth === 1) {
setCurrentYear((y) => y - 1);
setCurrentMonth(12);
} else {
setCurrentMonth((m) => m - 1);
}
};
const handleNextMonth = () => {
if (currentMonth === 12) {
setCurrentYear((y) => y + 1);
setCurrentMonth(1);
} else {
setCurrentMonth((m) => m + 1);
}
};
const weekHeaders = ['一', '二', '三', '四', '五', '六', '日'];
return (
<div
style={{
position: 'absolute',
top: '42px',
right: '16px',
zIndex: 50,
width: '280px',
background: 'var(--bg-panel, #1e222d)',
border: '1px solid var(--border-strong, #3a4256)',
borderRadius: 'var(--r-lg, 12px)',
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.45)',
padding: '14px',
userSelect: 'none',
backdropFilter: 'blur(12px)',
}}
>
{/* 头部控制栏 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '12px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<CalendarIcon size={14} color="var(--brand-400, #6b84ff)" />
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong, #f8fafc)' }}>
{currentYear}年 {currentMonth}月
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<button
onClick={handlePrevMonth}
style={{
padding: '3px',
borderRadius: '4px',
color: 'var(--text-muted, #94a3b8)',
background: 'transparent',
cursor: 'pointer',
}}
title="上个月"
>
<ChevronLeft size={16} />
</button>
<button
onClick={handleNextMonth}
style={{
padding: '3px',
borderRadius: '4px',
color: 'var(--text-muted, #94a3b8)',
background: 'transparent',
cursor: 'pointer',
}}
title="下个月"
>
<ChevronRight size={16} />
</button>
<button
onClick={onClose}
style={{
padding: '3px',
borderRadius: '4px',
color: 'var(--text-faint, #64748b)',
background: 'transparent',
cursor: 'pointer',
marginLeft: '4px',
}}
title="关闭日历"
>
<X size={16} />
</button>
</div>
</div>
{/* 星期表头 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
textAlign: 'center',
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-faint, #64748b)',
marginBottom: '6px',
}}
>
{weekHeaders.map((w) => (
<div key={w} style={{ padding: '4px 0' }}>
{w}
</div>
))}
</div>
{/* 日历格子 */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '4px',
}}
>
{/* 前置留白 */}
{Array.from({ length: firstDayWeek }).map((_, idx) => (
<div key={`blank-${idx}`} style={{ height: '32px' }} />
))}
{/* 当月日期 */}
{Array.from({ length: daysInMonth }).map((_, idx) => {
const dayNum = idx + 1;
const dayKey = `${currentYear}-${String(currentMonth).padStart(2, '0')}-${String(dayNum).padStart(2, '0')}`;
const msgCount = statMap.get(dayKey) || 0;
const hasMessages = msgCount > 0;
return (
<button
key={dayKey}
onClick={() => {
if (hasMessages) {
onSelectDate(dayKey);
onClose();
}
}}
disabled={!hasMessages}
style={{
height: '32px',
borderRadius: '6px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
background: hasMessages
? 'rgba(79, 107, 255, 0.12)'
: 'transparent',
color: hasMessages
? 'var(--text-strong, #f8fafc)'
: 'var(--text-faint, #475569)',
fontWeight: hasMessages ? 600 : 400,
fontSize: '11px',
cursor: hasMessages ? 'pointer' : 'default',
border: hasMessages
? '1px solid rgba(79, 107, 255, 0.3)'
: '1px solid transparent',
transition: 'all 0.15s ease',
}}
title={
hasMessages
? `${dayKey} (共 ${msgCount} 条对话,点击跳转)`
: `${dayKey} (无记录)`
}
onMouseEnter={(e) => {
if (hasMessages) {
e.currentTarget.style.background = 'rgba(79, 107, 255, 0.28)';
e.currentTarget.style.borderColor = 'var(--brand-500, #4f6bff)';
}
}}
onMouseLeave={(e) => {
if (hasMessages) {
e.currentTarget.style.background = 'rgba(79, 107, 255, 0.12)';
e.currentTarget.style.borderColor = 'rgba(79, 107, 255, 0.3)';
}
}}
>
<span>{dayNum}</span>
{hasMessages && (
<span
style={{
position: 'absolute',
bottom: '3px',
width: '4px',
height: '4px',
borderRadius: '50%',
background:
msgCount > 50
? 'var(--brand-400, #6b84ff)'
: 'var(--success, #10b981)',
}}
/>
)}
</button>
);
})}
</div>
</div>
);
};
+128
View File
@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
parseAnchorHash,
formatAnchorHash,
navigateToAnchor,
} from './anchorNavigator';
test('parseAnchorHash: 正确解析标准深链 hash', () => {
const t1 = parseAnchorHash('#/s/sess_001/m/1024');
assert.deepEqual(t1, { sessionId: 'sess_001', messageId: 1024 });
const t2 = parseAnchorHash('/s/sess_benchmark_long_001/m/23592');
assert.deepEqual(t2, { sessionId: 'sess_benchmark_long_001', messageId: 23592 });
assert.equal(parseAnchorHash(''), null);
assert.equal(parseAnchorHash('#/random/route'), null);
assert.equal(parseAnchorHash('#/s/sess_001/m/abc'), null);
assert.equal(parseAnchorHash('#/s/sess_001/m/-1'), null);
});
test('formatAnchorHash: 正确构造深链格式', () => {
const hash = formatAnchorHash('sess_001', 1024);
assert.equal(hash, '#/s/sess_001/m/1024');
});
test('navigateToAnchor: 快路径直接命中 DOM 节点', async () => {
let scrolled = false;
let blockOption = '';
let behaviorOption = '';
const mockElement = {
classList: {
classes: new Set<string>(),
add(c: string) {
this.classes.add(c);
},
remove(c: string) {
this.classes.delete(c);
},
contains(c: string) {
return this.classes.has(c);
},
},
scrollIntoView(options: { block?: string; behavior?: string }) {
scrolled = true;
blockOption = options?.block || '';
behaviorOption = options?.behavior || '';
},
offsetWidth: 100,
};
(globalThis as any).document = {
getElementById(id: string) {
if (id === 'chat-anchor-1024') return mockElement;
return null;
},
};
const success = await navigateToAnchor({
sessionId: 'sess_001',
messageId: 1024,
highlightMs: 50,
});
assert.equal(success, true);
assert.equal(scrolled, true);
assert.equal(blockOption, 'center');
assert.equal(behaviorOption, 'auto'); // 严格无动画
assert.equal(mockElement.classList.contains('chat-anchor-pulse'), true);
delete (globalThis as any).document;
});
test('navigateToAnchor: 慢路径拉取分片后命中', async () => {
let loadCalled = false;
let elementMounted = false;
const mockElement = {
classList: {
classes: new Set<string>(),
add(c: string) {
this.classes.add(c);
},
remove(c: string) {
this.classes.delete(c);
},
contains(c: string) {
return this.classes.has(c);
},
},
scrollIntoView() {},
offsetWidth: 100,
};
(globalThis as any).document = {
getElementById(id: string) {
if (id === 'chat-anchor-9999' && elementMounted) return mockElement;
return null;
},
};
const ctx = {
sessionId: 'sess_001',
loadDayForAnchor: async (msgId: number) => {
loadCalled = true;
if (msgId === 9999) {
elementMounted = true;
return true;
}
return false;
},
};
const success = await navigateToAnchor(
{
sessionId: 'sess_001',
messageId: 9999,
highlightMs: 50,
},
ctx
);
assert.equal(loadCalled, true);
assert.equal(success, true);
delete (globalThis as any).document;
});
+105
View File
@@ -0,0 +1,105 @@
/**
* 统一锚点定位协议引擎 (Anchor Navigation Protocol Engine)
* 遵循技术规范 7.1:
* 1. 快路径: 锚点已在当前窗口 DOM 中,瞬时居中对焦并触发 1.8s 发光脉冲
* 2. 慢路径: 锚点不在窗口中,调用上下文拉取目标日分片合并后对焦
* 3. 深链 Hash: #/s/{sessionId}/m/{messageId} 解析与状态同步
*/
export interface AnchorTarget {
messageId: number;
sessionId: string;
highlightMs?: number;
}
export interface ChatWindowNavContext {
sessionId: string;
loadDayForAnchor?: (messageId: number) => Promise<boolean>;
}
/**
* 格式化锚点 URL Hash
*/
export function formatAnchorHash(sessionId: string, messageId: number): string {
return `#/s/${sessionId}/m/${messageId}`;
}
/**
* 解析锚点 URL Hash
* 支持标准 #/s/{sessionId}/m/{messageId} 格式
*/
export function parseAnchorHash(hash: string): AnchorTarget | null {
if (!hash) return null;
const cleaned = hash.startsWith('#') ? hash.substring(1) : hash;
const match = cleaned.match(/^\/s\/([^/]+)\/m\/(\d+)$/);
if (!match) return null;
const sessionId = match[1];
const messageId = parseInt(match[2], 10);
if (isNaN(messageId) || messageId <= 0) return null;
return { sessionId, messageId };
}
/**
* 触发 DOM 元素的锚点高亮脉冲
*/
export function triggerAnchorHighlight(el: HTMLElement, highlightMs = 1800): void {
el.classList.remove('chat-anchor-pulse');
// 强制触发 reflow 使动画重新生效
void el.offsetWidth;
el.classList.add('chat-anchor-pulse');
setTimeout(() => {
el.classList.remove('chat-anchor-pulse');
}, highlightMs);
}
/**
* 将窗口导航定位到指定消息锚点
*/
export async function navigateToAnchor(
target: AnchorTarget,
ctx?: ChatWindowNavContext
): Promise<boolean> {
const elementId = `chat-anchor-${target.messageId}`;
const highlightMs = target.highlightMs ?? 1800;
// 1. 快路径: 目标已在当前 DOM 中
let el = typeof document !== 'undefined' ? document.getElementById(elementId) : null;
if (el) {
el.scrollIntoView({ block: 'center', behavior: 'auto' });
triggerAnchorHighlight(el, highlightMs);
if (typeof window !== 'undefined' && target.sessionId) {
window.location.hash = formatAnchorHash(target.sessionId, target.messageId);
}
return true;
}
// 2. 慢路径: 跨日加载
if (ctx && ctx.loadDayForAnchor) {
try {
const loaded = await ctx.loadDayForAnchor(target.messageId);
if (!loaded) {
return false;
}
// 等待 DOM 挂载完成 (next tick)
await new Promise((resolve) => setTimeout(resolve, 80));
el = document.getElementById(elementId);
if (el) {
el.scrollIntoView({ block: 'center', behavior: 'auto' });
triggerAnchorHighlight(el, highlightMs);
if (typeof window !== 'undefined' && target.sessionId) {
window.location.hash = formatAnchorHash(target.sessionId, target.messageId);
}
return true;
}
} catch (e) {
console.warn('[anchorNavigator] Slow path navigation failed:', e);
}
}
return false;
}
+106 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { ChatMessage, ChatDayBucket } from '../types/workbench';
import { ChatMessage, ChatDayBucket, MessageCategory } from '../types/workbench';
import {
fetchChatWindow,
fetchDayMessages,
@@ -32,6 +32,10 @@ export interface UseChatWindowReturn {
hasMoreBefore: boolean;
loadOlder: (container: HTMLElement | null) => Promise<void>;
loadFullDay: (dayKey: string) => Promise<void>;
jumpToDate: (dayKey: string) => Promise<void>;
loadDayForAnchor: (messageId: number) => Promise<boolean>;
selectedCategory: MessageCategory | 'all';
setSelectedCategory: (cat: MessageCategory | 'all') => void;
appendLiveMessage: (msg: ChatMessage) => void;
updateLiveMessage: (id: string, patch: Partial<ChatMessage>) => void;
replaceMessageServerId: (tempId: string, serverId: number) => void;
@@ -63,6 +67,7 @@ export function useChatWindow({
const [hasMoreBefore, setHasMoreBefore] = useState<boolean>(true);
const [oldestLoadedDay, setOldestLoadedDay] = useState<string>('');
const [selectedCategory, setSelectedCategory] = useState<MessageCategory | 'all'>('all');
const [draft, setDraftState] = useState<string>(() => loadSessionDraft(sessionId));
const setDraft = useCallback(
@@ -232,6 +237,102 @@ export function useChatWindow({
[sessionId]
);
// 日期跳转: 聚焦指定日期
const jumpToDate = useCallback(
async (dayKey: string) => {
const exists = dayStack.some((b) => b.dayKey === dayKey);
if (exists) {
if (typeof document !== 'undefined') {
const el = document.getElementById(`chat-day-divider-${dayKey}`);
if (el) {
el.scrollIntoView({ block: 'start', behavior: 'auto' });
}
}
return;
}
try {
const res = await fetchDayMessages(sessionId, dayKey, { limit: 100 });
if (res.messages.length > 0) {
const bucket: ChatDayBucket = {
dayKey: res.dayKey,
count: res.totalCount,
loadedCount: res.loadedCount,
truncated: res.totalCount > res.loadedCount,
firstMessageId: res.messages[0]?.serverId || 0,
lastMessageId: res.messages[res.messages.length - 1]?.serverId || 0,
messages: res.messages,
};
setDayStack((prev) => {
const next = prev.filter((b) => b.dayKey !== dayKey);
next.push(bucket);
next.sort((a, b) => (a.dayKey < b.dayKey ? -1 : 1));
if (next.length > MAX_WINDOW_DAYS_STACK) {
next.splice(0, next.length - MAX_WINDOW_DAYS_STACK);
}
saveSessionWindowCache(sessionId, next);
return next;
});
setTimeout(() => {
if (typeof document !== 'undefined') {
const el = document.getElementById(`chat-day-divider-${dayKey}`);
if (el) {
el.scrollIntoView({ block: 'start', behavior: 'auto' });
}
}
}, 60);
}
} catch (err) {
console.warn(`[useChatWindow] jumpToDate failed for ${dayKey}:`, err);
}
},
[sessionId, dayStack]
);
// 跨日锚点拉取: 根据 messageId 拉取所属日分片并合并进窗口
const loadDayForAnchor = useCallback(
async (messageId: number): Promise<boolean> => {
for (const b of dayStack) {
if (b.messages.some((m) => m.serverId === messageId)) {
return true;
}
}
try {
const res = await fetchDayMessages(sessionId, '', { anchorId: messageId, limit: 100 });
if (res.messages.length > 0) {
const bucket: ChatDayBucket = {
dayKey: res.dayKey,
count: res.totalCount,
loadedCount: res.loadedCount,
truncated: res.totalCount > res.loadedCount,
firstMessageId: res.messages[0]?.serverId || 0,
lastMessageId: res.messages[res.messages.length - 1]?.serverId || 0,
messages: res.messages,
};
setDayStack((prev) => {
const next = prev.filter((b) => b.dayKey !== res.dayKey);
next.push(bucket);
next.sort((a, b) => (a.dayKey < b.dayKey ? -1 : 1));
if (next.length > MAX_WINDOW_DAYS_STACK) {
next.splice(0, next.length - MAX_WINDOW_DAYS_STACK);
}
saveSessionWindowCache(sessionId, next);
return next;
});
return true;
}
} catch (err) {
console.warn(`[useChatWindow] loadDayForAnchor failed for message ${messageId}:`, err);
}
return false;
},
[sessionId, dayStack]
);
// 追加新消息 (SSE 或用户发送)
const appendLiveMessage = useCallback(
(msg: ChatMessage) => {
@@ -430,6 +531,10 @@ export function useChatWindow({
hasMoreBefore,
loadOlder,
loadFullDay,
jumpToDate,
loadDayForAnchor,
selectedCategory,
setSelectedCategory,
appendLiveMessage,
updateLiveMessage,
replaceMessageServerId,
+46
View File
@@ -79,3 +79,49 @@ input, textarea {
font-family: inherit;
outline: none;
}
/* ============================================================
P1 锚点跳转高亮脉冲效果 (Anchor Jump Pulse)
============================================================ */
@keyframes chat-anchor-flash {
0% {
box-shadow: 0 0 0 0 rgba(79, 107, 255, 0.7);
outline: 2px solid var(--brand-500);
outline-offset: 2px;
}
30% {
box-shadow: 0 0 20px 4px rgba(79, 107, 255, 0.85);
outline: 2px solid var(--brand-400);
outline-offset: 4px;
}
70% {
box-shadow: 0 0 10px 2px rgba(79, 107, 255, 0.4);
outline: 2px solid var(--brand-500);
outline-offset: 2px;
}
100% {
box-shadow: none;
outline: none;
outline-offset: 0;
}
}
.chat-anchor-pulse {
animation: chat-anchor-flash 1.8s ease-out forwards !important;
border-radius: var(--r-lg);
z-index: 2;
position: relative;
}
/* 分类筛选置灰/淡化模式 */
.chat-category-dimmed {
opacity: 0.35 !important;
filter: grayscale(0.5);
transition: opacity 0.2s ease, filter 0.2s ease;
}
.chat-category-highlighted {
opacity: 1 !important;
transform: scale(1.005);
transition: opacity 0.2s ease, transform 0.2s ease;
}
+8
View File
@@ -183,6 +183,14 @@ export interface ChatDateStat {
digestStatus?: string;
}
export interface ChatCategoryStat {
category: MessageCategory;
label: string;
count: number;
first_message_id: number;
last_message_id: number;
}
export type DeliverableCategory = 'workflow' | 'shared' | 'private';
export interface DeliverableItem {