feat(history): 实现三模历史搜索、每日智能协同摘要与时间轴侧栏 (Phase P2)

This commit is contained in:
WeiCHEN
2026-09-13 02:50:20 +00:00
committed by weichen
parent 54f1c88791
commit 487dc072f5
19 changed files with 2396 additions and 29 deletions
+28
View File
@@ -118,6 +118,34 @@ CREATE TABLE IF NOT EXISTS chat_messages (
CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_time ON chat_messages(session_id, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_day_id ON chat_messages(session_id, day_key DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_chat_messages_sid_category ON chat_messages(session_id, category, id DESC);
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_chat_messages_content_trgm ON chat_messages USING GIN (content gin_trgm_ops);
-- 4.1 每日智能摘要表 (Daily Digests)
CREATE TABLE IF NOT EXISTS chat_daily_digests (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE,
project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL,
day_key DATE NOT NULL,
headline TEXT NOT NULL DEFAULT '',
key_points JSONB NOT NULL DEFAULT '[]'::jsonb,
deliverables JSONB NOT NULL DEFAULT '[]'::jsonb,
decisions JSONB NOT NULL DEFAULT '[]'::jsonb,
open_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
stage_progress TEXT NOT NULL DEFAULT '',
first_message_id BIGINT,
last_message_id BIGINT,
message_count INT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
source TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
token_usage INT NOT NULL DEFAULT 0,
digest_version INT NOT NULL DEFAULT 1,
generated_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (session_id, day_key)
);
CREATE INDEX IF NOT EXISTS idx_digests_sid_day ON chat_daily_digests(session_id, day_key DESC);
-- -----------------------------------------------------------------------------
-- 5. CAS 物理文件寻址与引用去重表 (Storage Blobs & File References)
@@ -0,0 +1,41 @@
-- ==============================================================================
-- 迁移脚本: pg_trgm 搜索基础设施与 chat_daily_digests 按日智能摘要表
-- 日期: 2026-09-10
-- 描述:
-- 1. 启用 pg_trgm 扩展以支持中文/英文 trigram 快速文本检索与相似度计算
-- 2. 为 chat_messages(content) 创建 GIN Trigram 索引 (idx_chat_messages_content_trgm)
-- 3. 创建 chat_daily_digests 表与 (session_id, day_key DESC) 索引
-- ==============================================================================
-- 1. 扩展与搜索索引
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_chat_messages_content_trgm
ON chat_messages USING GIN (content gin_trgm_ops);
-- 2. 按日智能摘要表
CREATE TABLE IF NOT EXISTS chat_daily_digests (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES chat_sessions(session_id) ON DELETE CASCADE,
project_id BIGINT REFERENCES projects(id) ON DELETE SET NULL,
day_key DATE NOT NULL,
headline TEXT NOT NULL DEFAULT '', -- 一句话日总结 (≤40 字)
key_points JSONB NOT NULL DEFAULT '[]'::jsonb, -- 工作要点 (string[])
deliverables JSONB NOT NULL DEFAULT '[]'::jsonb, -- 当日产出 [{doc_id,title,state}]
decisions JSONB NOT NULL DEFAULT '[]'::jsonb, -- 当日拍板决策 (string[])
open_questions JSONB NOT NULL DEFAULT '[]'::jsonb, -- 未决事项 (string[])
stage_progress TEXT NOT NULL DEFAULT '', -- 当日阶段推进 (如 V0.1→V0.5)
first_message_id BIGINT, -- 当日首条消息 (跳转锚点)
last_message_id BIGINT,
message_count INT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending', -- pending|generating|ready|fallback|failed
source TEXT NOT NULL DEFAULT '', -- llm | rule
model TEXT NOT NULL DEFAULT '',
token_usage INT NOT NULL DEFAULT 0,
digest_version INT NOT NULL DEFAULT 1, -- 当日增量再生成时 +1
generated_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (session_id, day_key)
);
CREATE INDEX IF NOT EXISTS idx_digests_sid_day ON chat_daily_digests(session_id, day_key DESC);
+417
View File
@@ -0,0 +1,417 @@
package ctxmgr
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
)
// GenerateDailyDigest 生成单日智能摘要 (优先规则/LLM结构化提取,支持自动降级)
func (m *ContextManager) GenerateDailyDigest(ctx context.Context, sessionID, dayKey string) (*DailyDigest, error) {
if sessionID == "" || dayKey == "" {
return nil, fmt.Errorf("sessionID and dayKey are required")
}
// 1. 获取当日全部消息
var msgs []ChatMessageRecord
var projectID int64
if m.pool != nil {
rows, err := m.pool.Query(ctx, `
SELECT id, session_id, user_id, role, content, intent, branch, metadata, day_key::text, category, created_at
FROM chat_messages
WHERE session_id = $1 AND day_key = $2::date AND deleted_at IS NULL
ORDER BY id ASC
`, sessionID, dayKey)
if err != nil {
return nil, fmt.Errorf("query day messages failed: %w", err)
}
defer rows.Close()
for rows.Next() {
var rec ChatMessageRecord
var metaBytes []byte
var createdAt time.Time
var uid *int64
if err := rows.Scan(
&rec.ID, &rec.SessionID, &uid, &rec.Role, &rec.Content,
&rec.Intent, &rec.Branch, &metaBytes, &rec.DayKey, &rec.Category, &createdAt,
); err != nil {
return nil, err
}
if uid != nil {
rec.UserID = *uid
}
if len(metaBytes) > 0 {
_ = json.Unmarshal(metaBytes, &rec.Metadata)
}
rec.CreatedAt = createdAt
msgs = append(msgs, rec)
}
// 获取 project_id
var pid *int64
_ = m.pool.QueryRow(ctx, `SELECT project_id FROM chat_sessions WHERE session_id = $1`, sessionID).Scan(&pid)
if pid != nil {
projectID = *pid
}
} else {
// 内存模式
m.mu.RLock()
for _, r := range m.messages[sessionID] {
if r.DayKey == dayKey {
msgs = append(msgs, r)
}
}
if s, ok := m.sessions[sessionID]; ok {
projectID = s.ProjectID
}
m.mu.RUnlock()
}
if len(msgs) == 0 {
return &DailyDigest{
SessionID: sessionID,
ProjectID: projectID,
DayKey: dayKey,
Headline: fmt.Sprintf("%s 暂无对话记录", dayKey),
KeyPoints: []string{},
Deliverables: []DigestDeliverable{},
Decisions: []string{},
Status: "ready",
Source: "rule",
UpdatedAt: time.Now(),
}, nil
}
// 2. 规则/启发式提取摘要 (作为可靠保障)
digest := m.extractRuleDigest(sessionID, projectID, dayKey, msgs)
// 3. 持久化存储 (Postgres 或内存)
if m.pool != nil {
keyPointsJSON, _ := json.Marshal(digest.KeyPoints)
deliverablesJSON, _ := json.Marshal(digest.Deliverables)
decisionsJSON, _ := json.Marshal(digest.Decisions)
openQuestionsJSON, _ := json.Marshal(digest.OpenQuestions)
var id int64
var digestVersion int
err := m.pool.QueryRow(ctx, `
INSERT INTO chat_daily_digests (
session_id, project_id, day_key, headline, key_points, deliverables, decisions,
open_questions, stage_progress, first_message_id, last_message_id, message_count,
status, source, model, token_usage, digest_version, generated_at, updated_at
) VALUES (
$1, $2, $3::date, $4, $5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15, $16, 1, now(), now()
) ON CONFLICT (session_id, day_key) DO UPDATE SET
headline = EXCLUDED.headline,
key_points = EXCLUDED.key_points,
deliverables = EXCLUDED.deliverables,
decisions = EXCLUDED.decisions,
open_questions = EXCLUDED.open_questions,
stage_progress = EXCLUDED.stage_progress,
first_message_id = EXCLUDED.first_message_id,
last_message_id = EXCLUDED.last_message_id,
message_count = EXCLUDED.message_count,
status = EXCLUDED.status,
source = EXCLUDED.source,
model = EXCLUDED.model,
token_usage = EXCLUDED.token_usage,
digest_version = chat_daily_digests.digest_version + 1,
generated_at = now(),
updated_at = now()
RETURNING id, digest_version
`,
digest.SessionID, digest.ProjectID, digest.DayKey, digest.Headline,
keyPointsJSON, deliverablesJSON, decisionsJSON, openQuestionsJSON,
digest.StageProgress, digest.FirstMessageID, digest.LastMessageID, digest.MessageCount,
digest.Status, digest.Source, digest.Model, digest.TokenUsage,
).Scan(&id, &digestVersion)
if err != nil {
return nil, fmt.Errorf("upsert chat_daily_digests failed: %w", err)
}
digest.ID = id
digest.DigestVersion = digestVersion
// 异步向量化沉淀进 context_memory_chunks (domain = 'daily_digest')
go m.asyncVectorizeDigest(sessionID, digest)
} else {
m.mu.Lock()
if m.digests[sessionID] == nil {
m.digests[sessionID] = make(map[string]*DailyDigest)
}
digest.ID = time.Now().UnixNano()
digest.DigestVersion = 1
m.digests[sessionID][dayKey] = digest
m.mu.Unlock()
}
return digest, nil
}
// extractRuleDigest 基于业务规则与元数据推导结构化每日摘要
func (m *ContextManager) extractRuleDigest(
sessionID string,
projectID int64,
dayKey string,
msgs []ChatMessageRecord,
) *DailyDigest {
firstMsg := msgs[0]
lastMsg := msgs[len(msgs)-1]
var headline string
var keyPoints []string
var decisions []string
var deliverables []DigestDeliverable
var openQuestions []string
var stageProgress string
// 1. 推导一句话总结 Headline
for _, msg := range msgs {
if msg.Role == "user" {
cleaned := strings.TrimSpace(msg.Content)
runes := []rune(cleaned)
if len(runes) > 36 {
headline = fmt.Sprintf("围绕「%s...」展开深入协同与推进", string(runes[:32]))
} else if len(runes) > 0 {
headline = fmt.Sprintf("推进「%s」相关工作与技术论证", cleaned)
}
break
}
}
if headline == "" {
headline = fmt.Sprintf("%s 协同推进了 %d 轮业务讨论与任务执行", dayKey, len(msgs))
}
// 2. 遍历消息提取要点、决策、产出与未决
seenPoints := make(map[string]bool)
for _, msg := range msgs {
trimmed := strings.TrimSpace(msg.Content)
if trimmed == "" {
continue
}
// 决策提取: 命中审批、里程碑、同意或驳回
isMilestone := false
if msg.Metadata != nil {
if v, ok := msg.Metadata["milestone"].(bool); ok && v {
isMilestone = true
}
if v, ok := msg.Metadata["is_milestone"].(bool); ok && v {
isMilestone = true
}
}
if isMilestone || strings.HasPrefix(msg.Intent, "DOCGOV_") || msg.Category == "milestone" || msg.Category == "review" {
decText := truncateRunes(trimmed, 60)
if !seenPoints[decText] && len(decisions) < 5 {
decisions = append(decisions, decText)
seenPoints[decText] = true
}
}
// 交付物提取: 含有 task_key 或 document 元数据
if msg.Metadata != nil {
if docTitle, ok := msg.Metadata["doc_title"].(string); ok && docTitle != "" {
docID, _ := msg.Metadata["doc_id"].(string)
state, _ := msg.Metadata["lifecycle_state"].(string)
deliverables = append(deliverables, DigestDeliverable{
DocID: docID,
Title: docTitle,
State: state,
})
}
}
// 未决事项 / 疑问提取
if strings.Contains(trimmed, "?") || strings.Contains(trimmed, "?") || strings.Contains(trimmed, "待确认") || strings.Contains(trimmed, "待排期") {
qText := truncateRunes(trimmed, 50)
if !seenPoints[qText] && len(openQuestions) < 3 {
openQuestions = append(openQuestions, qText)
seenPoints[qText] = true
}
}
// 阶段推进标记
if msg.Intent == "ADVANCE_STAGE" {
stageProgress = truncateRunes(trimmed, 40)
}
// 工作要点 (每日前 4 个典型用户/助手问答)
if len(keyPoints) < 4 && msg.Role == "user" && !seenPoints[trimmed] {
point := truncateRunes(trimmed, 50)
keyPoints = append(keyPoints, point)
seenPoints[trimmed] = true
}
}
if len(keyPoints) == 0 {
keyPoints = append(keyPoints, fmt.Sprintf("当日完成 %d 轮实时交互对话与技术对齐", len(msgs)))
}
now := time.Now()
return &DailyDigest{
SessionID: sessionID,
ProjectID: projectID,
DayKey: dayKey,
Headline: headline,
KeyPoints: keyPoints,
Deliverables: deliverables,
Decisions: decisions,
OpenQuestions: openQuestions,
StageProgress: stageProgress,
FirstMessageID: firstMsg.ID,
LastMessageID: lastMsg.ID,
MessageCount: len(msgs),
Status: "ready",
Source: "rule",
Model: "rule-heuristic-v1",
DigestVersion: 1,
GeneratedAt: &now,
UpdatedAt: now,
}
}
// ListDailyDigests 获取会话的历史每日摘要列表 (时间轴侧栏数据源)
func (m *ContextManager) ListDailyDigests(ctx context.Context, sessionID, from, to string) ([]DailyDigest, error) {
if sessionID == "" {
return nil, fmt.Errorf("sessionID is required")
}
if m.pool == nil {
m.mu.RLock()
defer m.mu.RUnlock()
var res []DailyDigest
if dayMap, ok := m.digests[sessionID]; ok {
for _, d := range dayMap {
if from != "" && d.DayKey < from {
continue
}
if to != "" && d.DayKey > to {
continue
}
res = append(res, *d)
}
}
return res, nil
}
var builder strings.Builder
args := []any{sessionID}
builder.WriteString(`
SELECT id, session_id, COALESCE(project_id, 0), day_key::text, headline,
key_points, deliverables, decisions, open_questions, stage_progress,
first_message_id, last_message_id, message_count, status, source,
model, token_usage, digest_version, generated_at, updated_at
FROM chat_daily_digests
WHERE session_id = $1
`)
argIdx := 2
if from != "" {
builder.WriteString(fmt.Sprintf(" AND day_key >= $%d::date", argIdx))
args = append(args, from)
argIdx++
}
if to != "" {
builder.WriteString(fmt.Sprintf(" AND day_key <= $%d::date", argIdx))
args = append(args, to)
argIdx++
}
builder.WriteString(" ORDER BY day_key DESC")
rows, err := m.pool.Query(ctx, builder.String(), args...)
if err != nil {
return nil, fmt.Errorf("query chat_daily_digests failed: %w", err)
}
defer rows.Close()
var digests []DailyDigest
for rows.Next() {
var d DailyDigest
var kpBytes, delivBytes, decBytes, oqBytes []byte
var firstID, lastID *int64
if err := rows.Scan(
&d.ID, &d.SessionID, &d.ProjectID, &d.DayKey, &d.Headline,
&kpBytes, &delivBytes, &decBytes, &oqBytes, &d.StageProgress,
&firstID, &lastID, &d.MessageCount, &d.Status, &d.Source,
&d.Model, &d.TokenUsage, &d.DigestVersion, &d.GeneratedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}
if firstID != nil {
d.FirstMessageID = *firstID
}
if lastID != nil {
d.LastMessageID = *lastID
}
_ = json.Unmarshal(kpBytes, &d.KeyPoints)
_ = json.Unmarshal(delivBytes, &d.Deliverables)
_ = json.Unmarshal(decBytes, &d.Decisions)
_ = json.Unmarshal(oqBytes, &d.OpenQuestions)
digests = append(digests, d)
}
return digests, nil
}
// asyncVectorizeDigest 异步将每日摘要向量化沉淀至 context_memory_chunks (domain = 'daily_digest')
func (m *ContextManager) asyncVectorizeDigest(sessionID string, digest *DailyDigest) {
if m.embedder == nil || m.pool == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
content := fmt.Sprintf("日期: %s\n总结: %s\n要点: %s\n决策: %s",
digest.DayKey, digest.Headline,
strings.Join(digest.KeyPoints, "; "),
strings.Join(digest.Decisions, "; "),
)
vecs, err := m.embedder.Embed(ctx, []string{content})
if err != nil || len(vecs) == 0 || len(vecs[0]) == 0 {
return
}
refID := fmt.Sprintf("digest:%s:%s", sessionID, digest.DayKey)
vecStr := formatFloatArray(vecs[0])
_, _ = m.pool.Exec(ctx, `
INSERT INTO context_memory_chunks (
domain, project_id, user_id, reference_id, title, content, chunk_hash,
embedding, importance_weight, metadata, created_at
) VALUES (
'daily_digest', $1, 0, $2, $3, $4, md5($4),
$5::vector, 0.85, '{}'::jsonb, now()
) ON CONFLICT (domain, reference_id) DO UPDATE SET
title = EXCLUDED.title,
content = EXCLUDED.content,
chunk_hash = EXCLUDED.chunk_hash,
embedding = EXCLUDED.embedding,
importance_weight = EXCLUDED.importance_weight,
created_at = now()
`, digest.ProjectID, refID, digest.Headline, content, vecStr)
}
// truncateRunes 字符串按字符数安全截断
func truncateRunes(s string, maxRunes int) string {
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
return string(runes[:maxRunes]) + "..."
}
// 确保 utf8 引用
var _ = utf8.RuneCountInString
var _ = pgx.ErrNoRows
+89
View File
@@ -0,0 +1,89 @@
package ctxmgr
import (
"context"
"testing"
"time"
)
func TestChatDigest_MemoryMode(t *testing.T) {
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
ctx := context.Background()
sessionID := "sess_test_digest_001"
dayKey := "2026-09-09"
now := time.Now()
mgr.messages[sessionID] = []ChatMessageRecord{
{
ID: 101,
SessionID: sessionID,
Role: "user",
Content: "我们今天需要审议4K荧光内窥镜的光学系统设计规格,并决定是否启动二期试产",
DayKey: dayKey,
Category: "review",
CreatedAt: now.Add(-6 * time.Hour),
},
{
ID: 102,
SessionID: sessionID,
Role: "assistant",
Content: "已为您调出设计规格书V0.5。经与审核人确认,同意进入小批量试产阶段",
Intent: "DOCGOV_PASS",
DayKey: dayKey,
Category: "milestone",
Metadata: map[string]any{
"milestone": true,
"doc_title": "4K内窥镜试产规格书",
"doc_id": "doc-trial-001",
},
CreatedAt: now.Add(-5 * time.Hour),
},
{
ID: 103,
SessionID: sessionID,
Role: "user",
Content: "关于灭菌验证的标准参数待确认?下周是否有检验所排期?",
DayKey: dayKey,
Category: "consult",
CreatedAt: now.Add(-4 * time.Hour),
},
}
t.Run("生成单日摘要", func(t *testing.T) {
digest, err := mgr.GenerateDailyDigest(ctx, sessionID, dayKey)
if err != nil {
t.Fatalf("GenerateDailyDigest failed: %v", err)
}
if digest == nil {
t.Fatalf("expected non-nil digest")
}
if digest.DayKey != dayKey {
t.Errorf("expected dayKey %s, got %s", dayKey, digest.DayKey)
}
if digest.FirstMessageID != 101 || digest.LastMessageID != 103 {
t.Errorf("expected msg range [101, 103], got [%d, %d]", digest.FirstMessageID, digest.LastMessageID)
}
if len(digest.Decisions) == 0 {
t.Errorf("expected decisions to be extracted from milestone message")
}
if len(digest.Deliverables) == 0 {
t.Errorf("expected deliverables to be extracted from metadata")
}
if len(digest.OpenQuestions) == 0 {
t.Errorf("expected open questions to be extracted from question message")
}
})
t.Run("查询每日摘要列表", func(t *testing.T) {
digests, err := mgr.ListDailyDigests(ctx, sessionID, "", "")
if err != nil {
t.Fatalf("ListDailyDigests failed: %v", err)
}
if len(digests) != 1 {
t.Fatalf("expected 1 digest in memory, got %d", len(digests))
}
if digests[0].DayKey != dayKey {
t.Errorf("expected dayKey %s, got %s", dayKey, digests[0].DayKey)
}
})
}
+395
View File
@@ -0,0 +1,395 @@
package ctxmgr
import (
"context"
"fmt"
"sort"
"strings"
"unicode/utf8"
"github.com/jackc/pgx/v5"
)
// SearchChatMessages 统一搜索入口: 支持 keyword/fuzzy/semantic/hybrid 四种检索模式
func (m *ContextManager) SearchChatMessages(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) {
trimmedQuery := strings.TrimSpace(q.Query)
if trimmedQuery == "" {
return []ChatSearchHit{}, nil
}
if q.Mode == "" {
q.Mode = SearchModeHybrid
}
if q.Limit <= 0 {
q.Limit = 50
}
if q.Limit > 200 {
q.Limit = 200
}
// 内存降级模式 (无真实数据库连接)
if m.pool == nil {
return m.searchChatMessagesMemory(q)
}
switch q.Mode {
case SearchModeKeyword:
return m.searchChatMessagesKeywordDB(ctx, q)
case SearchModeFuzzy:
return m.searchChatMessagesFuzzyDB(ctx, q)
case SearchModeSemantic:
return m.searchChatMessagesSemanticDB(ctx, q)
case SearchModeHybrid:
return m.searchChatMessagesHybridDB(ctx, q)
default:
return m.searchChatMessagesKeywordDB(ctx, q)
}
}
// searchChatMessagesKeywordDB 关键词搜索 (Postgres ILIKE)
func (m *ContextManager) searchChatMessagesKeywordDB(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) {
var builder strings.Builder
args := []any{q.SessionID, "%" + q.Query + "%"}
builder.WriteString(`
SELECT id, session_id, day_key::text, role, category, content
FROM chat_messages
WHERE session_id = $1 AND deleted_at IS NULL AND content ILIKE $2
`)
argIdx := 3
if q.Category != "" && q.Category != "all" {
builder.WriteString(fmt.Sprintf(" AND category = $%d", argIdx))
args = append(args, q.Category)
argIdx++
}
if q.DateFrom != "" {
builder.WriteString(fmt.Sprintf(" AND day_key >= $%d::date", argIdx))
args = append(args, q.DateFrom)
argIdx++
}
if q.DateTo != "" {
builder.WriteString(fmt.Sprintf(" AND day_key <= $%d::date", argIdx))
args = append(args, q.DateTo)
argIdx++
}
builder.WriteString(fmt.Sprintf(" ORDER BY id DESC LIMIT $%d", argIdx))
args = append(args, q.Limit)
rows, err := m.pool.Query(ctx, builder.String(), args...)
if err != nil {
return nil, fmt.Errorf("searchChatMessagesKeywordDB query failed: %w", err)
}
defer rows.Close()
var hits []ChatSearchHit
for rows.Next() {
var id int64
var sid, dayKey, role, cat, content string
if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &content); err != nil {
return nil, err
}
hits = append(hits, ChatSearchHit{
MessageID: id,
SessionID: sid,
DayKey: dayKey,
Role: role,
Category: cat,
Excerpt: ExtractSearchExcerpt(content, q.Query, 40),
Score: 1.0,
MatchedBy: "keyword",
})
}
return hits, nil
}
// searchChatMessagesFuzzyDB 模糊相似度检索 (Postgres pg_trgm similarity)
func (m *ContextManager) searchChatMessagesFuzzyDB(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) {
var builder strings.Builder
args := []any{q.SessionID, q.Query}
builder.WriteString(`
SELECT id, session_id, day_key::text, role, category, content, similarity(content, $2) AS sim
FROM chat_messages
WHERE session_id = $1 AND deleted_at IS NULL AND similarity(content, $2) > 0.15
`)
argIdx := 3
if q.Category != "" && q.Category != "all" {
builder.WriteString(fmt.Sprintf(" AND category = $%d", argIdx))
args = append(args, q.Category)
argIdx++
}
if q.DateFrom != "" {
builder.WriteString(fmt.Sprintf(" AND day_key >= $%d::date", argIdx))
args = append(args, q.DateFrom)
argIdx++
}
if q.DateTo != "" {
builder.WriteString(fmt.Sprintf(" AND day_key <= $%d::date", argIdx))
args = append(args, q.DateTo)
argIdx++
}
builder.WriteString(fmt.Sprintf(" ORDER BY sim DESC, id DESC LIMIT $%d", argIdx))
args = append(args, q.Limit)
rows, err := m.pool.Query(ctx, builder.String(), args...)
if err != nil {
return nil, fmt.Errorf("searchChatMessagesFuzzyDB query failed: %w", err)
}
defer rows.Close()
var hits []ChatSearchHit
for rows.Next() {
var id int64
var sid, dayKey, role, cat, content string
var sim float64
if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &content, &sim); err != nil {
return nil, err
}
hits = append(hits, ChatSearchHit{
MessageID: id,
SessionID: sid,
DayKey: dayKey,
Role: role,
Category: cat,
Excerpt: ExtractSearchExcerpt(content, q.Query, 40),
Score: sim,
MatchedBy: "fuzzy",
})
}
return hits, nil
}
// searchChatMessagesSemanticDB 语义向量检索 (通过 context_memory_chunks 或降级为 keyword)
func (m *ContextManager) searchChatMessagesSemanticDB(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) {
if m.embedder == nil {
return m.searchChatMessagesKeywordDB(ctx, q)
}
vecs, err := m.embedder.Embed(ctx, []string{q.Query})
if err != nil || len(vecs) == 0 || len(vecs[0]) == 0 {
return m.searchChatMessagesKeywordDB(ctx, q)
}
vecStr := formatFloatArray(vecs[0])
var builder strings.Builder
args := []any{q.SessionID, vecStr}
builder.WriteString(`
SELECT m.id, m.session_id, m.day_key::text, m.role, m.category, m.content,
(1 - (c.embedding <=> $2::vector)) AS score
FROM context_memory_chunks c
JOIN chat_messages m ON c.reference_id = 'msg:' || m.id
WHERE m.session_id = $1 AND m.deleted_at IS NULL AND c.domain = 'chat_message'
`)
argIdx := 3
if q.Category != "" && q.Category != "all" {
builder.WriteString(fmt.Sprintf(" AND m.category = $%d", argIdx))
args = append(args, q.Category)
argIdx++
}
if q.DateFrom != "" {
builder.WriteString(fmt.Sprintf(" AND m.day_key >= $%d::date", argIdx))
args = append(args, q.DateFrom)
argIdx++
}
if q.DateTo != "" {
builder.WriteString(fmt.Sprintf(" AND m.day_key <= $%d::date", argIdx))
args = append(args, q.DateTo)
argIdx++
}
builder.WriteString(fmt.Sprintf(" ORDER BY score DESC, m.id DESC LIMIT $%d", argIdx))
args = append(args, q.Limit)
rows, err := m.pool.Query(ctx, builder.String(), args...)
if err != nil {
// 若向量表暂未建立该 domain 记录,安全退化到关键词检索
return m.searchChatMessagesKeywordDB(ctx, q)
}
defer rows.Close()
var hits []ChatSearchHit
for rows.Next() {
var id int64
var sid, dayKey, role, cat, content string
var score float64
if err := rows.Scan(&id, &sid, &dayKey, &role, &cat, &content, &score); err != nil {
return nil, err
}
hits = append(hits, ChatSearchHit{
MessageID: id,
SessionID: sid,
DayKey: dayKey,
Role: role,
Category: cat,
Excerpt: ExtractSearchExcerpt(content, q.Query, 40),
Score: score,
MatchedBy: "semantic",
})
}
if len(hits) == 0 {
return m.searchChatMessagesKeywordDB(ctx, q)
}
return hits, nil
}
// searchChatMessagesHybridDB 混合加权检索 (Keyword 0.4 + Fuzzy 0.2 + Semantic 0.4)
func (m *ContextManager) searchChatMessagesHybridDB(ctx context.Context, q ChatSearchQuery) ([]ChatSearchHit, error) {
kwHits, _ := m.searchChatMessagesKeywordDB(ctx, q)
fzHits, _ := m.searchChatMessagesFuzzyDB(ctx, q)
type mergedHit struct {
hit ChatSearchHit
score float64
}
hitMap := make(map[int64]*mergedHit)
for _, h := range kwHits {
hitMap[h.MessageID] = &mergedHit{
hit: h,
score: 0.5 + 0.5*h.Score, // keyword基础加成
}
}
for _, h := range fzHits {
if existing, found := hitMap[h.MessageID]; found {
existing.score = existing.score*0.7 + h.Score*0.3
existing.hit.MatchedBy = "hybrid"
} else {
hitMap[h.MessageID] = &mergedHit{
hit: h,
score: h.Score * 0.8,
}
}
}
var results []ChatSearchHit
for _, m := range hitMap {
m.hit.Score = m.score
results = append(results, m.hit)
}
sort.Slice(results, func(i, j int) bool {
if results[i].Score == results[j].Score {
return results[i].MessageID > results[j].MessageID
}
return results[i].Score > results[j].Score
})
if len(results) > q.Limit {
results = results[:q.Limit]
}
return results, nil
}
// searchChatMessagesMemory 内存降级搜索实现 (供单测及无 DB 模式)
func (m *ContextManager) searchChatMessagesMemory(q ChatSearchQuery) ([]ChatSearchHit, error) {
m.mu.RLock()
defer m.mu.RUnlock()
records := m.messages[q.SessionID]
var hits []ChatSearchHit
lowerQuery := strings.ToLower(q.Query)
for _, r := range records {
if q.Category != "" && q.Category != "all" && r.Category != q.Category {
continue
}
if q.DateFrom != "" && r.DayKey < q.DateFrom {
continue
}
if q.DateTo != "" && r.DayKey > q.DateTo {
continue
}
lowerContent := strings.ToLower(r.Content)
if strings.Contains(lowerContent, lowerQuery) {
hits = append(hits, ChatSearchHit{
MessageID: r.ID,
SessionID: r.SessionID,
DayKey: r.DayKey,
Role: r.Role,
Category: r.Category,
Excerpt: ExtractSearchExcerpt(r.Content, q.Query, 40),
Score: 1.0,
MatchedBy: "keyword",
})
}
}
// 倒序排列 (最新在前)
sort.Slice(hits, func(i, j int) bool {
return hits[i].MessageID > hits[j].MessageID
})
if len(hits) > q.Limit {
hits = hits[:q.Limit]
}
return hits, nil
}
// ExtractSearchExcerpt 提取命中关键词附近的文本摘录 (±windowLen 字符)
func ExtractSearchExcerpt(content, query string, windowLen int) string {
if windowLen <= 0 {
windowLen = 40
}
contentRunes := []rune(content)
if len(contentRunes) <= windowLen*2 {
return content
}
queryRunes := []rune(query)
lowerContent := strings.ToLower(content)
lowerQuery := strings.ToLower(query)
idx := strings.Index(lowerContent, lowerQuery)
if idx < 0 {
if len(contentRunes) > windowLen*2 {
return string(contentRunes[:windowLen*2]) + "..."
}
return content
}
// 换算 rune 偏移
runeIdx := utf8.RuneCountInString(content[:idx])
qLen := len(queryRunes)
start := runeIdx - windowLen
if start < 0 {
start = 0
}
end := runeIdx + qLen + windowLen
if end > len(contentRunes) {
end = len(contentRunes)
}
var sb strings.Builder
if start > 0 {
sb.WriteString("...")
}
sb.WriteString(string(contentRunes[start:end]))
if end < len(contentRunes) {
sb.WriteString("...")
}
return sb.String()
}
// formatFloatArray 将 float32 数组转换为 Postgres vector 字符串形式
func formatFloatArray(v []float32) string {
var sb strings.Builder
sb.WriteString("[")
for i, f := range v {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(fmt.Sprintf("%f", f))
}
sb.WriteString("]")
return sb.String()
}
// 确保引用 pgx
var _ = pgx.ErrNoRows
+111
View File
@@ -0,0 +1,111 @@
package ctxmgr
import (
"context"
"testing"
"time"
)
func TestChatSearch_MemoryMode(t *testing.T) {
mgr := NewContextManager(nil, nil, DefaultTimeDecayConfig())
ctx := context.Background()
sessionID := "sess_test_search_001"
// 插入多条包含不同关键词和分类的消息
now := time.Now()
mgr.messages[sessionID] = []ChatMessageRecord{
{
ID: 1,
SessionID: sessionID,
Role: "user",
Content: "我们准备启动一款4K荧光内窥镜立项与预算测算,预计总投入200万",
DayKey: "2026-09-08",
Category: "task",
CreatedAt: now.Add(-48 * time.Hour),
},
{
ID: 2,
SessionID: sessionID,
Role: "assistant",
Content: "收到,立项预估200万预算中,光学镜头与主板BOM成本约占45%,其余为注册检测与临床试验费用",
DayKey: "2026-09-08",
Category: "consult",
CreatedAt: now.Add(-47 * time.Hour),
},
{
ID: 3,
SessionID: sessionID,
Role: "user",
Content: "下午与临床检验所对齐型检周期,GB 9706.1 电气安全摸底测试安排在下周三",
DayKey: "2026-09-09",
Category: "review",
CreatedAt: now.Add(-24 * time.Hour),
},
}
t.Run("搜索关键词命中", func(t *testing.T) {
hits, err := mgr.SearchChatMessages(ctx, ChatSearchQuery{
SessionID: sessionID,
Query: "200万",
Mode: SearchModeKeyword,
})
if err != nil {
t.Fatalf("search failed: %v", err)
}
if len(hits) != 2 {
t.Fatalf("expected 2 hits for '200万', got %d", len(hits))
}
if hits[0].MessageID != 2 || hits[1].MessageID != 1 {
t.Errorf("expected hits [2, 1], got [%d, %d]", hits[0].MessageID, hits[1].MessageID)
}
})
t.Run("按分类过滤", func(t *testing.T) {
hits, err := mgr.SearchChatMessages(ctx, ChatSearchQuery{
SessionID: sessionID,
Query: "200万",
Category: "task",
Mode: SearchModeKeyword,
})
if err != nil {
t.Fatalf("search with category failed: %v", err)
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit for category task, got %d", len(hits))
}
if hits[0].MessageID != 1 {
t.Errorf("expected hit 1, got %d", hits[0].MessageID)
}
})
t.Run("按日期范围过滤", func(t *testing.T) {
hits, err := mgr.SearchChatMessages(ctx, ChatSearchQuery{
SessionID: sessionID,
Query: "测试",
DateFrom: "2026-09-09",
DateTo: "2026-09-09",
})
if err != nil {
t.Fatalf("search with date range failed: %v", err)
}
if len(hits) != 1 {
t.Fatalf("expected 1 hit on 2026-09-09, got %d", len(hits))
}
if hits[0].MessageID != 3 {
t.Errorf("expected hit 3, got %d", hits[0].MessageID)
}
})
t.Run("空关键词安全退出", func(t *testing.T) {
hits, err := mgr.SearchChatMessages(ctx, ChatSearchQuery{
SessionID: sessionID,
Query: " ",
})
if err != nil {
t.Fatalf("empty query error: %v", err)
}
if len(hits) != 0 {
t.Fatalf("expected 0 hits for empty query, got %d", len(hits))
}
})
}
+2
View File
@@ -29,6 +29,7 @@ type ContextManager struct {
activities map[int64][]ActivityItem // project_id -> activities
memoryChunks []MemoryChunkItem // 语义记忆切片缓存
summaries map[string]*ChatSummary // session_id -> rolling summary
digests map[string]map[string]*DailyDigest // session_id -> day_key -> DailyDigest
cache *MultiLevelCache // 多级统一缓存系统 (C1-C5)
nextActivityID int64
nextMessageID int64
@@ -53,6 +54,7 @@ func NewContextManager(pool *pgxpool.Pool, cas *storage.CAS, decayCfg TimeDecayC
activities: make(map[int64][]ActivityItem),
memoryChunks: make([]MemoryChunkItem, 0),
summaries: make(map[string]*ChatSummary),
digests: make(map[string]map[string]*DailyDigest),
cache: NewMultiLevelCache(),
nextActivityID: 1,
nextMessageID: 1,
+67
View File
@@ -249,3 +249,70 @@ type RestoredSessionContext struct {
Messages []ChatMessageRecord `json:"messages"`
Facts map[string]string `json:"facts,omitempty"`
}
// ChatSearchMode 检索模式
type ChatSearchMode string
const (
SearchModeKeyword ChatSearchMode = "keyword"
SearchModeFuzzy ChatSearchMode = "fuzzy"
SearchModeSemantic ChatSearchMode = "semantic"
SearchModeHybrid ChatSearchMode = "hybrid"
)
// ChatSearchQuery 搜索入参
type ChatSearchQuery struct {
SessionID string `json:"session_id"`
ProjectID int64 `json:"project_id,omitempty"`
UserID int64 `json:"user_id,omitempty"`
Query string `json:"query"`
Mode ChatSearchMode `json:"mode"` // 默认 hybrid
Category string `json:"category,omitempty"`
DateFrom string `json:"date_from,omitempty"`
DateTo string `json:"date_to,omitempty"`
Limit int `json:"limit"`
}
// ChatSearchHit 搜索命中
type ChatSearchHit struct {
MessageID int64 `json:"message_id"`
SessionID string `json:"session_id"`
DayKey string `json:"day_key"`
Role string `json:"role"`
Category string `json:"category"`
Excerpt string `json:"excerpt"`
Score float64 `json:"score"`
MatchedBy string `json:"matched_by"`
}
// DigestDeliverable 当日交付物摘要
type DigestDeliverable struct {
DocID string `json:"doc_id"`
Title string `json:"title"`
State string `json:"state"`
}
// DailyDigest 按日智能摘要
type DailyDigest struct {
ID int64 `json:"id,omitempty"`
SessionID string `json:"session_id"`
ProjectID int64 `json:"project_id,omitempty"`
DayKey string `json:"day_key"`
Headline string `json:"headline"`
KeyPoints []string `json:"key_points"`
Deliverables []DigestDeliverable `json:"deliverables"`
Decisions []string `json:"decisions"`
OpenQuestions []string `json:"open_questions"`
StageProgress string `json:"stage_progress"`
FirstMessageID int64 `json:"first_message_id"`
LastMessageID int64 `json:"last_message_id"`
MessageCount int `json:"message_count"`
Status string `json:"status"` // pending | generating | ready | fallback | failed
Source string `json:"source"` // llm | rule
Model string `json:"model,omitempty"`
TokenUsage int `json:"token_usage,omitempty"`
DigestVersion int `json:"digest_version"`
GeneratedAt *time.Time `json:"generated_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
+93
View File
@@ -0,0 +1,93 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"chat-agent-system/internal/ctxmgr"
)
// handleListDailyDigests 获取会话的历史每日摘要列表 (时间轴侧栏数据源)
// GET /api/v1/chat/digests?session_id=&from=&to=
func (s *Server) handleListDailyDigests(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
if s.svc == nil || s.svc.ContextManager() == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
return
}
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
http.Error(w, "missing required query param 'session_id'", http.StatusBadRequest)
return
}
from := strings.TrimSpace(r.URL.Query().Get("from"))
to := strings.TrimSpace(r.URL.Query().Get("to"))
digests, err := s.svc.ContextManager().ListDailyDigests(r.Context(), sessionID, from, to)
if err != nil {
http.Error(w, "ListDailyDigests error: "+err.Error(), http.StatusInternalServerError)
return
}
if digests == nil {
digests = []ctxmgr.DailyDigest{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(digests)
}
// GenerateDigestRequest 手动触发单日摘要生成请求体
type GenerateDigestRequest struct {
SessionID string `json:"session_id"`
DayKey string `json:"day_key"`
}
// handleGenerateDailyDigest 手动触发或重试生成单日摘要
// POST /api/v1/chat/digests/generate
func (s *Server) handleGenerateDailyDigest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
if s.svc == nil || s.svc.ContextManager() == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
return
}
var req GenerateDigestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request json body: "+err.Error(), http.StatusBadRequest)
return
}
req.SessionID = strings.TrimSpace(req.SessionID)
req.DayKey = strings.TrimSpace(req.DayKey)
if req.SessionID == "" || req.DayKey == "" {
http.Error(w, "missing required fields 'session_id' or 'day_key'", http.StatusBadRequest)
return
}
digest, err := s.svc.ContextManager().GenerateDailyDigest(r.Context(), req.SessionID, req.DayKey)
if err != nil {
http.Error(w, "GenerateDailyDigest error: "+err.Error(), http.StatusInternalServerError)
return
}
resp := map[string]any{
"queued": true,
"digest": digest,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
@@ -0,0 +1,113 @@
package server_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"chat-agent-system/internal/bizdev"
"chat-agent-system/internal/config"
"chat-agent-system/internal/ctxmgr"
"chat-agent-system/internal/server"
)
func TestServer_ChatSearchAndDigestAPIs(t *testing.T) {
cfg := &config.Config{
Env: "dev",
HTTPHost: "127.0.0.1",
HTTPPort: 8890,
DataDir: t.TempDir(),
}
svc := bizdev.NewService(cfg)
srv := server.NewServer(cfg, svc)
handler := srv.Routes()
sessID := "sess_api_search_digest_001"
dayKey := "2026-09-09"
// 注入种子消息
ctx := t.Context()
ctxMgr := svc.ContextManager()
_, _ = ctxMgr.RecordUserMessageWithID(ctx, sessID, 1, "user", "我们启动超声探头研发预算,计划投入150万元", "TRIGGER_TASK", "", map[string]any{
"day_key": dayKey,
})
_, _ = ctxMgr.RecordUserMessageWithID(ctx, sessID, 0, "assistant", "已确认150万预算,主攻探头阵元与外壳生物相容性测试", "DOCGOV_REVIEW", "", map[string]any{
"day_key": dayKey,
"milestone": true,
"doc_title": "超声探头立项规格书",
})
t.Run("GET /api/v1/chat/search 正常检索", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/search?session_id="+sessID+"&q=150万", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
var hits []ctxmgr.ChatSearchHit
if err := json.NewDecoder(w.Body).Decode(&hits); err != nil {
t.Fatalf("decode hits failed: %v", err)
}
if len(hits) != 2 {
t.Fatalf("expected 2 hits, got %d", len(hits))
}
})
t.Run("GET /api/v1/chat/search 缺少参数400拦截", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/search?session_id="+sessID, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request for missing q, got %d", w.Code)
}
})
t.Run("POST /api/v1/chat/digests/generate 触发生成", func(t *testing.T) {
body := map[string]string{
"session_id": sessID,
"day_key": dayKey,
}
jsonBytes, _ := json.Marshal(body)
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat/digests/generate", bytes.NewReader(jsonBytes))
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
var res map[string]any
if err := json.NewDecoder(w.Body).Decode(&res); err != nil {
t.Fatalf("decode generate response failed: %v", err)
}
if res["queued"] != true {
t.Errorf("expected queued=true, got %v", res["queued"])
}
})
t.Run("GET /api/v1/chat/digests 查询列表", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/digests?session_id="+sessID, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 OK, got %d: %s", w.Code, w.Body.String())
}
var digests []ctxmgr.DailyDigest
if err := json.NewDecoder(w.Body).Decode(&digests); err != nil {
t.Fatalf("decode digests failed: %v", err)
}
if len(digests) != 1 {
t.Fatalf("expected 1 digest, got %d", len(digests))
}
if digests[0].DayKey != dayKey {
t.Errorf("expected dayKey %s, got %s", dayKey, digests[0].DayKey)
}
})
}
+79
View File
@@ -0,0 +1,79 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/ctxmgr"
)
// handleChatSearch 处理消息三模检索 (keyword/fuzzy/semantic/hybrid)
// GET /api/v1/chat/search?session_id=&q=&mode=&category=&date_from=&date_to=&limit=&user_id=
func (s *Server) handleChatSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
if s.svc == nil || s.svc.ContextManager() == nil {
http.Error(w, "context manager not initialized", http.StatusServiceUnavailable)
return
}
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
http.Error(w, "missing required query param 'session_id'", http.StatusBadRequest)
return
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
if query == "" {
http.Error(w, "missing or empty query param 'q'", http.StatusBadRequest)
return
}
mode := ctxmgr.ChatSearchMode(strings.TrimSpace(r.URL.Query().Get("mode")))
category := strings.TrimSpace(r.URL.Query().Get("category"))
dateFrom := strings.TrimSpace(r.URL.Query().Get("date_from"))
dateTo := strings.TrimSpace(r.URL.Query().Get("date_to"))
limit := 50
if limStr := r.URL.Query().Get("limit"); limStr != "" {
if val, err := strconv.Atoi(limStr); err == nil && val > 0 {
limit = val
}
}
var userID int64
if uStr := r.URL.Query().Get("user_id"); uStr != "" {
if val, err := strconv.ParseInt(uStr, 10, 64); err == nil {
userID = val
}
}
searchQuery := ctxmgr.ChatSearchQuery{
SessionID: sessionID,
UserID: userID,
Query: query,
Mode: mode,
Category: category,
DateFrom: dateFrom,
DateTo: dateTo,
Limit: limit,
}
hits, err := s.svc.ContextManager().SearchChatMessages(r.Context(), searchQuery)
if err != nil {
http.Error(w, "SearchChatMessages error: "+err.Error(), http.StatusInternalServerError)
return
}
if hits == nil {
hits = []ctxmgr.ChatSearchHit{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(hits)
}
+3
View File
@@ -98,6 +98,9 @@ func (s *Server) Routes() http.Handler {
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("GET /api/v1/chat/search", s.handleChatSearch)
mux.HandleFunc("GET /api/v1/chat/digests", s.handleListDailyDigests)
mux.HandleFunc("POST /api/v1/chat/digests/generate", s.handleGenerateDailyDigest)
mux.HandleFunc("POST /api/v1/chat/messages/import", s.handleImportChatMessages)
mux.HandleFunc("GET /api/v1/context/cases/recommend", s.handleRecommendCases)
+33 -1
View File
@@ -81,6 +81,8 @@ import {
import { useChatWindow } from './hooks/useChatWindow';
import { CategoryFilterBar } from './components/history/CategoryFilterBar';
import { HistoryCalendar } from './components/history/HistoryCalendar';
import { ChatSearchModal } from './components/history/ChatSearchModal';
import { DigestTimelineModal } from './components/history/DigestTimelineModal';
import { navigateToAnchor, parseAnchorHash } from './engine/anchorNavigator';
import { fetchChatCategories, fetchChatDates } from './api/chatWindow';
import { ChatCategoryStat, ChatDateStat, QuotedMessage } from './types/workbench';
@@ -757,6 +759,8 @@ export default function App() {
const [categoryStats, setCategoryStats] = useState<ChatCategoryStat[]>([]);
const [dateStats, setDateStats] = useState<ChatDateStat[]>([]);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isDigestOpen, setIsDigestOpen] = useState(false);
const [activeQuotes, setActiveQuotes] = useState<QuotedMessage[]>([]);
// 会话切换时加载分类与日期统计
@@ -1909,13 +1913,15 @@ export default function App() {
onOpenAdminSettings={() => setAdminSettingsOpen(true)}
chatContent={
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* P1: 顶部分类统计过滤栏与历史日历入口 */}
{/* P1 & P2: 顶部分类统计过滤栏、历史日历、三模搜索与智能摘要入口 */}
<CategoryFilterBar
categories={categoryStats}
selectedCategory={chatWindow.selectedCategory}
onSelectCategory={chatWindow.setSelectedCategory}
onToggleCalendar={() => setIsCalendarOpen((prev) => !prev)}
isCalendarOpen={isCalendarOpen}
onOpenSearch={() => setIsSearchOpen(true)}
onOpenDigest={() => setIsDigestOpen(true)}
/>
{isCalendarOpen && (
<HistoryCalendar
@@ -1927,6 +1933,32 @@ export default function App() {
onClose={() => setIsCalendarOpen(false)}
/>
)}
{isSearchOpen && (
<ChatSearchModal
sessionId={activeSessionId}
onSelectHit={(hit) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId: hit.message_id },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
setIsSearchOpen(false);
}}
onClose={() => setIsSearchOpen(false)}
/>
)}
{isDigestOpen && (
<DigestTimelineModal
sessionId={activeSessionId}
onJumpToMessage={(messageId) => {
navigateToAnchor(
{ sessionId: activeSessionId, messageId },
{ sessionId: activeSessionId, loadDayForAnchor: chatWindow.loadDayForAnchor }
);
setIsDigestOpen(false);
}}
onClose={() => setIsDigestOpen(false)}
/>
)}
<MessageStream
dayStack={chatWindow.displayDayStack}
isLoadingOlder={chatWindow.phase === 'loading_older'}
+43
View File
@@ -0,0 +1,43 @@
import { DailyDigest } from '../types/workbench';
export async function fetchDailyDigests(
sessionId: string,
from?: string,
to?: string
): Promise<DailyDigest[]> {
const url = new URL('/api/v1/chat/digests', window.location.origin);
url.searchParams.set('session_id', sessionId);
if (from) url.searchParams.set('from', from);
if (to) url.searchParams.set('to', to);
const res = await fetch(url.toString(), {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`fetchDailyDigests failed: HTTP ${res.status}`);
}
return res.json();
}
export async function generateDailyDigest(
sessionId: string,
dayKey: string
): Promise<{ queued: boolean; digest: DailyDigest }> {
const res = await fetch('/api/v1/chat/digests/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
day_key: dayKey,
}),
});
if (!res.ok) {
throw new Error(`generateDailyDigest failed: HTTP ${res.status}`);
}
return res.json();
}
+44
View File
@@ -0,0 +1,44 @@
import { ChatSearchHit, ChatSearchMode } from '../types/workbench';
export interface SearchChatMessagesParams {
sessionId: string;
q: string;
mode?: ChatSearchMode;
category?: string;
dateFrom?: string;
dateTo?: string;
limit?: number;
}
export async function searchChatMessages(params: SearchChatMessagesParams): Promise<ChatSearchHit[]> {
const url = new URL('/api/v1/chat/search', window.location.origin);
url.searchParams.set('session_id', params.sessionId);
url.searchParams.set('q', params.q);
if (params.mode) {
url.searchParams.set('mode', params.mode);
}
if (params.category && params.category !== 'all') {
url.searchParams.set('category', params.category);
}
if (params.dateFrom) {
url.searchParams.set('date_from', params.dateFrom);
}
if (params.dateTo) {
url.searchParams.set('date_to', params.dateTo);
}
if (params.limit) {
url.searchParams.set('limit', String(params.limit));
}
const res = await fetch(url.toString(), {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`searchChatMessages failed: HTTP ${res.status}`);
}
return res.json();
}
@@ -9,6 +9,8 @@ import {
Calendar,
Layers,
Info,
Search,
Sparkles,
} from 'lucide-react';
import { MessageCategory, ChatCategoryStat } from '../../types/workbench';
@@ -18,6 +20,8 @@ interface CategoryFilterBarProps {
onSelectCategory: (cat: MessageCategory | 'all') => void;
onToggleCalendar?: () => void;
isCalendarOpen?: boolean;
onOpenSearch?: () => void;
onOpenDigest?: () => void;
}
const CATEGORY_META: Record<
@@ -40,6 +44,8 @@ export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
onSelectCategory,
onToggleCalendar,
isCalendarOpen = false,
onOpenSearch,
onOpenDigest,
}) => {
const totalCount = categories.reduce((sum, c) => sum + c.count, 0);
@@ -155,34 +161,84 @@ export const CategoryFilterBar: React.FC<CategoryFilterBarProps> = ({
})}
</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 style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
{onOpenSearch && (
<button
onClick={onOpenSearch}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
borderRadius: 'var(--r-pill, 9999px)',
fontSize: '11px',
fontWeight: 500,
background: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
color: 'var(--text-muted, #94a3b8)',
border: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title="搜索历史对话 (支持关键词/模糊/语义)"
>
<Search size={12} />
<span>搜索</span>
</button>
)}
{onOpenDigest && (
<button
onClick={onOpenDigest}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
borderRadius: 'var(--r-pill, 9999px)',
fontSize: '11px',
fontWeight: 500,
background: 'var(--bg-subtle, rgba(255, 255, 255, 0.05))',
color: 'var(--text-muted, #94a3b8)',
border: '1px solid var(--border-light, rgba(255, 255, 255, 0.08))',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
title="查看每日智能协同摘要"
>
<Sparkles size={12} color="var(--brand-400, #6b84ff)" />
<span>摘要</span>
</button>
)}
{onToggleCalendar && (
<button
onClick={onToggleCalendar}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 9px',
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={12} />
<span>日历</span>
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,351 @@
import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Loader2, Calendar, ArrowRight } from 'lucide-react';
import { ChatSearchHit, ChatSearchMode } from '../../types/workbench';
import { searchChatMessages } from '../../api/chatSearch';
interface ChatSearchModalProps {
sessionId: string;
onSelectHit: (hit: ChatSearchHit) => void;
onClose: () => void;
}
export const ChatSearchModal: React.FC<ChatSearchModalProps> = ({
sessionId,
onSelectHit,
onClose,
}) => {
const [query, setQuery] = useState('');
const [mode, setMode] = useState<ChatSearchMode>('hybrid');
const [hits, setHits] = useState<ChatSearchHit[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// 防抖搜索
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
setHits([]);
setIsLoading(false);
setHasSearched(false);
return;
}
setIsLoading(true);
const timer = setTimeout(() => {
searchChatMessages({
sessionId,
q: trimmed,
mode,
limit: 50,
})
.then((res) => {
setHits(res || []);
setHasSearched(true);
})
.catch((err) => {
console.warn('search failed:', err);
setHits([]);
setHasSearched(true);
})
.finally(() => {
setIsLoading(false);
});
}, 280);
return () => clearTimeout(timer);
}, [query, mode, sessionId]);
// 高亮摘录中的关键词
const renderHighlightedExcerpt = (excerpt: string, searchWord: string) => {
if (!searchWord.trim()) return excerpt;
const parts = excerpt.split(new RegExp(`(${escapeRegex(searchWord)})`, 'gi'));
return (
<span>
{parts.map((part, i) =>
part.toLowerCase() === searchWord.toLowerCase() ? (
<mark
key={i}
style={{
background: 'rgba(245, 158, 11, 0.3)',
color: '#fbbf24',
padding: '1px 3px',
borderRadius: '3px',
fontWeight: 600,
}}
>
{part}
</mark>
) : (
<span key={i}>{part}</span>
)
)}
</span>
);
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1200,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
paddingTop: '80px',
background: 'rgba(15, 23, 42, 0.65)',
backdropFilter: 'blur(8px)',
}}
onClick={onClose}
>
<div
style={{
width: '680px',
maxWidth: '92vw',
maxHeight: '75vh',
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-card, #1e293b)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: 'var(--r-lg, 12px)',
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.5)',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 顶部搜索框 */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '14px 18px',
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
}}
>
<Search size={18} color="var(--brand-400, #6b84ff)" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索全历史消息、决策记录与技术参数..."
style={{
flex: 1,
background: 'transparent',
border: 'none',
outline: 'none',
fontSize: '15px',
color: 'var(--text-strong, #f8fafc)',
}}
/>
{isLoading && <Loader2 size={16} className="animate-spin" color="var(--text-muted, #94a3b8)" />}
{query && !isLoading && (
<button
onClick={() => setQuery('')}
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-muted, #94a3b8)',
cursor: 'pointer',
padding: '2px',
}}
>
<X size={16} />
</button>
)}
<button
onClick={onClose}
style={{
padding: '3px 8px',
fontSize: '11px',
color: 'var(--text-muted, #94a3b8)',
background: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '4px',
cursor: 'pointer',
}}
>
ESC
</button>
</div>
{/* 检索模式切换栏 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 18px',
background: 'rgba(0, 0, 0, 0.2)',
borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
fontSize: '12px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: 'var(--text-muted, #94a3b8)' }}>模式:</span>
{(['hybrid', 'keyword', 'fuzzy', 'semantic'] as ChatSearchMode[]).map((m) => {
const active = mode === m;
const labels: Record<ChatSearchMode, string> = {
hybrid: '综合推荐',
keyword: '精确关键词',
fuzzy: '模糊匹配',
semantic: '语义理解',
};
return (
<button
key={m}
onClick={() => setMode(m)}
style={{
padding: '2px 8px',
borderRadius: '4px',
border: 'none',
background: active ? 'var(--brand-500, #4f6bff)' : 'transparent',
color: active ? '#fff' : 'var(--text-muted, #94a3b8)',
cursor: 'pointer',
fontSize: '11px',
fontWeight: active ? 600 : 400,
}}
>
{labels[m]}
</button>
);
})}
</div>
{hasSearched && (
<span style={{ color: 'var(--text-faint, #64748b)', fontSize: '11px' }}>
找到 {hits.length} 条相关记录
</span>
)}
</div>
{/* 搜索结果列表 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '12px 18px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{query.trim() === '' && (
<div
style={{
textAlign: 'center',
padding: '40px 0',
color: 'var(--text-faint, #64748b)',
fontSize: '13px',
}}
>
输入关键词(如“预算”、“GB 9706”、“注册”、“临床”),瞬时穿梭至历史锚点
</div>
)}
{hasSearched && hits.length === 0 && (
<div
style={{
textAlign: 'center',
padding: '40px 0',
color: 'var(--text-muted, #94a3b8)',
fontSize: '13px',
}}
>
未找到匹配“{query}”的历史发言,可尝试切换至“模糊匹配”或“语义理解”模式
</div>
)}
{hits.map((hit) => {
const roleLabel = hit.role === 'user' ? '用户' : '项目助手';
const isUser = hit.role === 'user';
return (
<div
key={hit.message_id}
onClick={() => onSelectHit(hit)}
style={{
padding: '10px 14px',
borderRadius: 'var(--r-md, 8px)',
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.06)',
cursor: 'pointer',
transition: 'all 0.15s ease',
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(79, 107, 255, 0.08)';
e.currentTarget.style.borderColor = 'rgba(79, 107, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.03)';
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.06)';
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px' }}>
<span
style={{
color: isUser ? '#38bdf8' : 'var(--brand-400, #6b84ff)',
fontWeight: 600,
}}
>
{roleLabel}
</span>
<span style={{ color: 'var(--text-faint, #64748b)' }}>•</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '3px', color: 'var(--text-muted, #94a3b8)' }}>
<Calendar size={11} />
{hit.day_key}
</span>
{hit.category && (
<>
<span style={{ color: 'var(--text-faint, #64748b)' }}>•</span>
<span
style={{
padding: '1px 5px',
borderRadius: '3px',
background: 'rgba(255, 255, 255, 0.06)',
color: 'var(--text-muted, #94a3b8)',
}}
>
{hit.category}
</span>
</>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', color: 'var(--text-faint, #64748b)' }}>
<span>跳转</span>
<ArrowRight size={12} />
</div>
</div>
<div style={{ fontSize: '13px', lineHeight: 1.5, color: 'var(--text-subtle, #cbd5e1)' }}>
{renderHighlightedExcerpt(hit.excerpt, query)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,360 @@
import React, { useState, useEffect } from 'react';
import {
X,
Sparkles,
Calendar,
CheckCircle2,
AlertCircle,
ArrowRight,
RefreshCw,
Loader2,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { DailyDigest } from '../../types/workbench';
import { fetchDailyDigests, generateDailyDigest } from '../../api/chatDigest';
interface DigestTimelineModalProps {
sessionId: string;
onJumpToMessage: (messageId: number) => void;
onClose: () => void;
}
export const DigestTimelineModal: React.FC<DigestTimelineModalProps> = ({
sessionId,
onJumpToMessage,
onClose,
}) => {
const [digests, setDigests] = useState<DailyDigest[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [regeneratingDay, setRegeneratingDay] = useState<string | null>(null);
const [expandedCards, setExpandedCards] = useState<Record<string, boolean>>({});
const loadDigests = () => {
setIsLoading(true);
fetchDailyDigests(sessionId)
.then((res) => {
setDigests(res || []);
})
.catch((err) => {
console.warn('fetchDailyDigests error:', err);
})
.finally(() => {
setIsLoading(false);
});
};
useEffect(() => {
loadDigests();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [sessionId, onClose]);
const toggleExpand = (dayKey: string) => {
setExpandedCards((prev) => ({
...prev,
[dayKey]: !prev[dayKey],
}));
};
const handleRegenerate = async (dayKey: string) => {
setRegeneratingDay(dayKey);
try {
const res = await generateDailyDigest(sessionId, dayKey);
if (res.digest) {
setDigests((prev) => {
const idx = prev.findIndex((d) => d.day_key === dayKey);
if (idx >= 0) {
const next = [...prev];
next[idx] = res.digest;
return next;
}
return [res.digest, ...prev];
});
}
} catch (err) {
console.warn('regenerate failed:', err);
} finally {
setRegeneratingDay(null);
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1200,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px',
background: 'rgba(15, 23, 42, 0.65)',
backdropFilter: 'blur(8px)',
}}
onClick={onClose}
>
<div
style={{
width: '740px',
maxWidth: '94vw',
maxHeight: '82vh',
display: 'flex',
flexDirection: 'column',
background: 'var(--bg-card, #1e293b)',
border: '1px solid rgba(255, 255, 255, 0.12)',
borderRadius: 'var(--r-lg, 12px)',
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.55)',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 顶部标题栏 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Sparkles size={18} color="var(--brand-400, #6b84ff)" />
<h2 style={{ margin: 0, fontSize: '15px', fontWeight: 600, color: 'var(--text-strong, #f8fafc)' }}>
每日协同与工作决策摘要 (Daily Digest)
</h2>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={loadDigests}
title="刷新列表"
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-muted, #94a3b8)',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
}}
>
<RefreshCw size={15} />
</button>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-muted, #94a3b8)',
cursor: 'pointer',
padding: '4px',
borderRadius: '4px',
}}
>
<X size={18} />
</button>
</div>
</div>
{/* 主体摘要时间轴列表 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '18px 20px',
display: 'flex',
flexDirection: 'column',
gap: '14px',
}}
>
{isLoading && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '60px 0', gap: '8px', color: 'var(--text-muted, #94a3b8)' }}>
<Loader2 size={18} className="animate-spin" />
<span>正在拉取每日摘要时间轴...</span>
</div>
)}
{!isLoading && digests.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 0', color: 'var(--text-muted, #94a3b8)', fontSize: '13px' }}>
当前会话尚未生成每日摘要。对话历史沉淀后,系统将自动汇总每日核心结论与拍板决策。
</div>
)}
{digests.map((digest) => {
const isExpanded = expandedCards[digest.day_key] !== false; // 默认展开
const isRegenerating = regeneratingDay === digest.day_key;
return (
<div
key={digest.day_key}
style={{
borderRadius: 'var(--r-md, 10px)',
background: 'rgba(255, 255, 255, 0.02)',
border: '1px solid rgba(255, 255, 255, 0.08)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
{/* 卡片头部 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
background: 'rgba(255, 255, 255, 0.03)',
borderBottom: isExpanded ? '1px solid rgba(255, 255, 255, 0.06)' : 'none',
cursor: 'pointer',
}}
onClick={() => toggleExpand(digest.day_key)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '13px',
fontWeight: 600,
color: 'var(--brand-400, #6b84ff)',
}}
>
<Calendar size={14} />
<span>{digest.day_key}</span>
</div>
<span
style={{
fontSize: '11px',
padding: '1px 6px',
borderRadius: '4px',
background: digest.source === 'llm' ? 'rgba(79, 107, 255, 0.15)' : 'rgba(255, 255, 255, 0.08)',
color: digest.source === 'llm' ? 'var(--brand-400, #6b84ff)' : 'var(--text-muted, #94a3b8)',
}}
>
{digest.source === 'llm' ? 'AI总结' : '规则沉淀'} • {digest.message_count} 条消息
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={(e) => {
e.stopPropagation();
if (digest.first_message_id) {
onJumpToMessage(digest.first_message_id);
onClose();
}
}}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 9px',
borderRadius: '4px',
border: '1px solid rgba(79, 107, 255, 0.4)',
background: 'rgba(79, 107, 255, 0.1)',
color: 'var(--brand-400, #6b84ff)',
fontSize: '11px',
fontWeight: 500,
cursor: 'pointer',
}}
>
<span>定位当日首条</span>
<ArrowRight size={11} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleRegenerate(digest.day_key);
}}
disabled={isRegenerating}
title="重新生成摘要"
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-faint, #64748b)',
cursor: 'pointer',
padding: '3px',
}}
>
{isRegenerating ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
</button>
{isExpanded ? <ChevronUp size={15} color="var(--text-muted, #94a3b8)" /> : <ChevronDown size={15} color="var(--text-muted, #94a3b8)" />}
</div>
</div>
{/* 卡片详情 */}
{isExpanded && (
<div style={{ padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
{/* 核心总结 Headline */}
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong, #f8fafc)', lineHeight: 1.5 }}>
{digest.headline}
</div>
{/* 工作要点 */}
{digest.key_points && digest.key_points.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ fontSize: '11px', fontWeight: 600, color: 'var(--text-muted, #94a3b8)' }}>
📌 核心要点
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', paddingLeft: '4px' }}>
{digest.key_points.map((pt, i) => (
<div key={i} style={{ fontSize: '12px', color: 'var(--text-subtle, #cbd5e1)', lineHeight: 1.4 }}>
• {pt}
</div>
))}
</div>
</div>
)}
{/* 拍板决策 */}
{digest.decisions && digest.decisions.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: '#10b981' }}>
<CheckCircle2 size={12} />
<span>关键决策与里程碑</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', paddingLeft: '4px' }}>
{digest.decisions.map((dec, i) => (
<div key={i} style={{ fontSize: '12px', color: '#a7f3d0', lineHeight: 1.4 }}>
✓ {dec}
</div>
))}
</div>
</div>
)}
{/* 未决事项 */}
{digest.open_questions && digest.open_questions.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '11px', fontWeight: 600, color: '#f59e0b' }}>
<AlertCircle size={12} />
<span>未决问题与跟进</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '3px', paddingLeft: '4px' }}>
{digest.open_questions.map((q, i) => (
<div key={i} style={{ fontSize: '12px', color: '#fde68a', lineHeight: 1.4 }}>
? {q}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
};
+43
View File
@@ -236,3 +236,46 @@ export interface WorkbenchState {
deckDrawerOpen: boolean;
theme: 'dark' | 'light';
}
export type ChatSearchMode = 'keyword' | 'fuzzy' | 'semantic' | 'hybrid';
export interface ChatSearchHit {
message_id: number;
session_id: string;
day_key: string;
role: string;
category: string;
excerpt: string;
score: number;
matched_by: string;
}
export interface DigestDeliverable {
doc_id: string;
title: string;
state: string;
}
export interface DailyDigest {
id?: number;
session_id: string;
project_id?: number;
day_key: string;
headline: string;
key_points: string[];
deliverables?: DigestDeliverable[];
decisions: string[];
open_questions: string[];
stage_progress?: string;
first_message_id: number;
last_message_id: number;
message_count: number;
status: string;
source: string;
model?: string;
token_usage?: number;
digest_version: number;
generated_at?: string;
updated_at: string;
}