feat(history): 实现三模历史搜索、每日智能协同摘要与时间轴侧栏 (Phase P2)
This commit is contained in:
@@ -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);
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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'}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user