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:
@@ -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 "未分类"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user