diff --git a/agent/compact.go b/agent/compact.go index 5f9c3fc..ad6782f 100644 --- a/agent/compact.go +++ b/agent/compact.go @@ -250,7 +250,7 @@ func EstimatePromptTokens(workspace, skillCatalog, summary string, history []Cha // [lastSystemPrompt] + history[:keepStart] + [尾部压缩指令],并带上 lastToolSpecsJSON 还原的 // 工具集 —— 这串前缀正是上次缓存下来的,几乎全命中,只有尾部指令是 miss。 // lastSystemPrompt 为空(无快照)时退回冷路径:compressionPrompt 当 system + 拍平历史。 -func RunCompression(lastSystemPrompt, lastToolSpecsJSON string, history []ChatMessage, entry ModelEntry, ctxWin int) ( +func RunCompression(lastSystemPrompt, lastToolSpecsJSON string, history []ChatMessage, entry ModelEntry, ctxWin int, focusHint string) ( summary string, cutIdx int, compressedTurns int, err error) { // 轮数按 isTurnBoundary(user 或 assistant)计:一个 user 消息 + 几十轮工具调用同样是几十轮对话, @@ -332,17 +332,25 @@ func RunCompression(lastSystemPrompt, lastToolSpecsJSON string, history []ChatMe convo := make([]ChatMessage, 0, keepStart+2) convo = append(convo, ChatMessage{Role: "system", Content: lastSystemPrompt}) convo = append(convo, history[:keepStart]...) - convo = append(convo, ChatMessage{Role: "user", Content: warmCompressInstruction}) + instruction := warmCompressInstruction + if focusHint != "" { + instruction = fmt.Sprintf("%s\n\n**压缩侧重点**: 请重点关注与[%s]相关的内容, 保留相关决策和上下文; 与侧重点无关的内容可以更激进地压缩。", instruction, focusHint) + } + convo = append(convo, ChatMessage{Role: "user", Content: instruction}) toolSpecs := UnmarshalToolSpecs(lastToolSpecsJSON) summary, err = CallWithTools(ctx, entry.APIKey, entry.BaseURL, entry.Model, convo, toolSpecs, summaryMax) } else { // 冷路径:无快照,拍平历史走独立 system(必 miss,但正确)。 + cp := compressionPrompt + if focusHint != "" { + cp = fmt.Sprintf("%s\n\n**压缩侧重点**: 请重点关注与[%s]相关的内容, 保留相关决策和上下文; 与侧重点无关的内容可以更激进地压缩。", cp, focusHint) + } var inputBuf strings.Builder for _, msg := range history[:keepStart] { inputBuf.WriteString("[" + msg.Role + "]\n" + msg.Content + "\n\n") } convo := []ChatMessage{ - {Role: "system", Content: compressionPrompt}, + {Role: "system", Content: cp}, {Role: "user", Content: inputBuf.String()}, } summary, err = CallOnce(ctx, entry.APIKey, entry.BaseURL, entry.Model, convo, summaryMax) diff --git a/agent/compact_cooldown_test.go b/agent/compact_cooldown_test.go index c6c134b..2742a4b 100644 --- a/agent/compact_cooldown_test.go +++ b/agent/compact_cooldown_test.go @@ -18,7 +18,7 @@ func TestRunCompression_TooFewTurnsSentinel(t *testing.T) { {Role: "user", Content: "一"}, {Role: "assistant", Content: "回一"}, } // 2 轮(user + assistant),不多于要保留的 keepRecentTurns - _, _, _, err := RunCompression("", "", hist, ModelEntry{ContextWindow: 100000}, 100000) + _, _, _, err := RunCompression("", "", hist, ModelEntry{ContextWindow: 100000}, 100000, "") if !errors.Is(err, ErrCompactTooFewTurns) { t.Fatalf("2 轮应返回 ErrCompactTooFewTurns 哨兵, got %v", err) } @@ -34,7 +34,7 @@ func TestRunCompression_SingleUserLongTurnNotRejected(t *testing.T) { hist = append(hist, asstCall(id, "Bash", `{"command":"go test"}`), toolMsg(id, "Bash", body)) } // BaseURL 为空 → 摘要请求在本地就失败;这里只关心它已越过轮数 / 切点守卫。 - _, _, _, err := RunCompression("sys", "[]", hist, ModelEntry{ContextWindow: 20000}, 20000) + _, _, _, err := RunCompression("sys", "[]", hist, ModelEntry{ContextWindow: 20000}, 20000, "") if errors.Is(err, ErrCompactTooFewTurns) { t.Fatal("单个 user 长任务轮不应再被判成轮数不足") } diff --git a/agent/compact_live_test.go b/agent/compact_live_test.go index eb1ceac..5ab55c7 100644 --- a/agent/compact_live_test.go +++ b/agent/compact_live_test.go @@ -62,7 +62,7 @@ func TestLive_RunCompressionSucceeds(t *testing.T) { } t.Logf("历史 ≈ %d tokens", EstimateHistoryTokens(hist)) - summary, cutIdx, turns, err := RunCompression("", "", hist, entry, ctxWin) + summary, cutIdx, turns, err := RunCompression("", "", hist, entry, ctxWin, "") if err != nil { t.Fatalf("❌ 真实压缩失败(正常路径不该失败): %v", err) } diff --git a/agent/embedder.go b/agent/embedder.go new file mode 100644 index 0000000..0ff5fe1 --- /dev/null +++ b/agent/embedder.go @@ -0,0 +1,33 @@ +package agent + +// Embedder 生成文本的语义向量, 用于主题相似度计算。 +// 两种实现: TF-IDF (稀疏, 零依赖) 和 ONNX (稠密, 语义级)。 +type Embedder interface { + // Embed 返回文本的语义向量。 + Embed(text string) map[string]float64 + // Name 返回嵌入器名称。 + Name() string +} + +// EmbedderType 嵌入器类型。 +type EmbedderType string + +const ( + EmbedderTFIDF EmbedderType = "tfidf" // 默认: TF-IDF 稀疏向量 + EmbedderONNX EmbedderType = "onnx" // ONNX Sentence Embeddings +) + +// NewEmbedder 创建嵌入器实例。 +// t 为类型, cacheDir 为模型缓存目录(仅 ONNX 需要)。 +func NewEmbedder(t EmbedderType, cacheDir string) (Embedder, error) { + switch t { + case EmbedderONNX: + emb, err := newONNXEmbedder(cacheDir) + if err != nil { + return nil, err + } + return emb, nil + default: + return newTFIDFEmbedder(), nil + } +} diff --git a/agent/embedder_onnx.go b/agent/embedder_onnx.go new file mode 100644 index 0000000..d2cb676 --- /dev/null +++ b/agent/embedder_onnx.go @@ -0,0 +1,270 @@ +package agent + +import ( + "fmt" + "io" + "math" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + ort "github.com/getcharzp/onnxruntime_purego" +) + +// onnxEmbedder 使用 ONNX Sentence Embeddings 模型生成语义向量。 +// 模型: all-MiniLM-L6-v2 (384维), 首次使用从 HuggingFace 下载。 +type onnxEmbedder struct { + mu sync.Mutex + session *ort.Session + vocab map[string]int32 // WordPiece 词汇表 + ready bool +} + +// DefaultONNXModelURL 默认 ONNX 模型下载地址(HuggingFace 镜像, 国内可用)。 +const DefaultONNXModelURL = "https://hf-mirror.com/sentence-transformers/all-MiniLM-L6-v2/resolve/main/onnx/model.onnx" + +// DefaultONNXVocabURL 默认词汇表下载地址(HuggingFace 镜像, 国内可用)。 +const DefaultONNXVocabURL = "https://hf-mirror.com/sentence-transformers/all-MiniLM-L6-v2/resolve/main/vocab.txt" + +const onnxModelFile = "embedder_model.onnx" +const onnxVocabFile = "embedder_vocab.txt" + +func newONNXEmbedder(cacheDir string) (*onnxEmbedder, error) { + if err := os.MkdirAll(cacheDir, 0700); err != nil { + return nil, err + } + modelPath := filepath.Join(cacheDir, onnxModelFile) + vocabPath := filepath.Join(cacheDir, onnxVocabFile) + + // 下载模型 + if _, err := os.Stat(modelPath); os.IsNotExist(err) { + if err := downloadFileHTTP(DefaultONNXModelURL, modelPath); err != nil { + return nil, fmt.Errorf("下载 ONNX 模型失败: %w", err) + } + } + // 下载词汇表 + if _, err := os.Stat(vocabPath); os.IsNotExist(err) { + if err := downloadFileHTTP(DefaultONNXVocabURL, vocabPath); err != nil { + return nil, fmt.Errorf("下载词汇表失败: %w", err) + } + } + + // 加载词汇表 + e := &onnxEmbedder{} + if err := e.loadVocab(vocabPath); err != nil { + return nil, fmt.Errorf("加载词汇表失败: %w", err) + } + + // 获取共享 ONNX Runtime 引擎 + engine, err := GetORTEngine(cacheDir) + if err != nil { + return nil, fmt.Errorf("ONNX Runtime 不可用: %w", err) + } + + // 创建推理会话(单线程即可, 向量化很快) + session, err := engine.NewSession(modelPath, 1) + if err != nil { + return nil, fmt.Errorf("创建 ONNX 会话失败: %w", err) + } + e.session = session + e.ready = true + return e, nil +} + +// loadVocab 加载 WordPiece 词汇表。 +func (e *onnxEmbedder) loadVocab(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + lines := strings.Split(string(data), "\n") + e.vocab = make(map[string]int32, len(lines)) + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + e.vocab[line] = int32(i) + } + return nil +} + +// Embed 生成文本的语义向量(384维, 归一化)。 +func (e *onnxEmbedder) Embed(text string) map[string]float64 { + if !e.ready || e.session == nil { + return nil + } + if text == "" { + return nil + } + + e.mu.Lock() + defer e.mu.Unlock() + + // 分词: 转换为 token IDs + attention mask + inputIDs, attentionMask := e.tokenize(text) + if len(inputIDs) == 0 { + return nil + } + + // 构造输入张量 + inputTensor, err := ort.NewTensor([]int64{1, int64(len(inputIDs))}, inputIDs) + if err != nil { + return nil + } + defer inputTensor.Destroy() + + maskTensor, err := ort.NewTensor([]int64{1, int64(len(attentionMask))}, attentionMask) + if err != nil { + return nil + } + defer maskTensor.Destroy() + + // ONNX 推理 + outputs, err := e.session.Run(map[string]*ort.Value{ + "input_ids": inputTensor, + "attention_mask": maskTensor, + }) + if err != nil || len(outputs) == 0 { + return nil + } + // 获取输出(模型输出名通常为 "sentence_embedding") + var outVal *ort.Value + for _, v := range outputs { + outVal = v + break + } + defer outVal.Destroy() + + // 提取向量, 384维 float32 + raw, err := ort.GetTensorData[float32](outVal) + if err != nil { + return nil + } + + // 转换为 float64 并归一化 + vec := make(map[string]float64, 384) + var norm float64 + for i, v := range raw { + vec[fmt.Sprintf("d%d", i)] = float64(v) + norm += float64(v) * float64(v) + } + norm = math.Sqrt(norm) + if norm > 0 { + for k := range vec { + vec[k] /= norm + } + } + return vec +} + +func (e *onnxEmbedder) Name() string { return "onnx(all-MiniLM-L6-v2)" } + +// tokenize 将文本转换为 token IDs 和 attention mask。 +// 使用 WordPiece 分词算法(与 BERT 兼容)。 +func (e *onnxEmbedder) tokenize(text string) ([]int64, []int64) { + const maxLen = 128 + ids := make([]int64, 0, maxLen+2) + mask := make([]int64, 0, maxLen+2) + + // [CLS] token (id=101 in BERT vocab) + ids = append(ids, 101) + mask = append(mask, 1) + + // 对文本进行分词 + runes := []rune(text) + i := 0 + for i < len(runes) && len(ids) < maxLen { + r := runes[i] + if isCJK(r) { + // CJK: 每个字符单独查找词汇表 + tok := strings.ToLower(string(r)) + if id, ok := e.vocab[tok]; ok { + ids = append(ids, int64(id)) + } else { + ids = append(ids, 100) // [UNK] + } + mask = append(mask, 1) + i++ + } else if isLetterOrDigit(r) { + // 英文单词: 累积到空格或标点 + var buf strings.Builder + for i < len(runes) && isLetterOrDigit(runes[i]) { + buf.WriteRune(runes[i]) + i++ + } + word := strings.ToLower(buf.String()) + subIDs := e.wordpiece(word) + ids = append(ids, subIDs...) + for range subIDs { + mask = append(mask, 1) + } + } else { + i++ + } + } + + // [SEP] token (id=102) + ids = append(ids, 102) + mask = append(mask, 1) + + return ids, mask +} + +// wordpiece 将英文单词切分为子词。 +func (e *onnxEmbedder) wordpiece(word string) []int64 { + if len(word) == 0 { + return nil + } + var ids []int64 + start := 0 + runes := []rune(word) + for start < len(runes) { + end := len(runes) + found := false + for end > start { + sub := string(runes[start:end]) + if start > 0 { + sub = "##" + sub + } + if id, ok := e.vocab[sub]; ok { + ids = append(ids, int64(id)) + start = end + found = true + break + } + end-- + } + if !found { + ids = append(ids, 100) // [UNK] + start++ + } + } + return ids +} + +func downloadFileHTTP(url, path string) error { + resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(url) + if err != nil { + return fmt.Errorf("无法下载 %s: %w", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载返回 %s", resp.Status) + } + tmpPath := path + ".tmp" + f, err := os.Create(tmpPath) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(f, io.LimitReader(resp.Body, 200<<20)); err != nil { + os.Remove(tmpPath) + return err + } + f.Close() + return os.Rename(tmpPath, path) +} diff --git a/agent/embedder_tfidf.go b/agent/embedder_tfidf.go new file mode 100644 index 0000000..4b22e28 --- /dev/null +++ b/agent/embedder_tfidf.go @@ -0,0 +1,85 @@ +package agent + +import ( + "math" + "sort" +) + +// tfidfEmbedder 使用 TF-IDF 将文本映射为稀疏向量。 +// 纯本地运行, 零外部依赖, 零 API 调用。 +type tfidfEmbedder struct { + docFreq map[string]int // 文档频率(用于 IDF) + totalDocs int +} + +func newTFIDFEmbedder() *tfidfEmbedder { + return &tfidfEmbedder{ + docFreq: make(map[string]int), + } +} + +func (e *tfidfEmbedder) Name() string { return "tfidf" } + +// Embed 计算文本的 TF-IDF 向量。 +// 分词工作由 Segmenter 完成, 嵌入器只负责向量化。 +func (e *tfidfEmbedder) Embed(text string) map[string]float64 { + tokens := tokenize(text) + if len(tokens) == 0 { + return nil + } + e.updateDocFreq(tokens) + return e.extractTFIDF(tokens) +} + +func (e *tfidfEmbedder) extractTFIDF(tokens []string) map[string]float64 { + tf := make(map[string]int) + for _, t := range tokens { + tf[t]++ + } + vec := make(map[string]float64, len(tf)) + for term, count := range tf { + tfVal := float64(count) / float64(len(tokens)) + df := e.docFreq[term] + 1 + idf := math.Log(float64(e.totalDocs+1) / float64(df)) + vec[term] = tfVal * idf + } + return vec +} + +func (e *tfidfEmbedder) updateDocFreq(tokens []string) { + seen := make(map[string]bool, len(tokens)) + for _, t := range tokens { + if seen[t] { + continue + } + seen[t] = true + e.docFreq[t]++ + } + e.totalDocs++ +} + +// TopKeywords 从 TF-IDF 向量中取 top N 关键词。 +func (e *tfidfEmbedder) TopKeywords(vec map[string]float64, n int) []string { + type kv struct { + k string + v float64 + } + pairs := make([]kv, 0, len(vec)) + for k, v := range vec { + pairs = append(pairs, kv{k, v}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].v != pairs[j].v { + return pairs[i].v > pairs[j].v + } + return len([]rune(pairs[i].k)) > len([]rune(pairs[j].k)) + }) + if n > len(pairs) { + n = len(pairs) + } + out := make([]string, n) + for i := 0; i < n; i++ { + out[i] = pairs[i].k + } + return out +} diff --git a/agent/keyword_router.go b/agent/keyword_router.go index 17e458d..efd421e 100644 --- a/agent/keyword_router.go +++ b/agent/keyword_router.go @@ -1,6 +1,9 @@ package agent -import "strings" +import ( + "strings" + "sync" +) // RouteByKeyword 是入口路由的确定性版本 — 纯本地、零延迟,替代之前的 LLM classifier。 // @@ -188,3 +191,112 @@ var complexKeywords = []string{ "조사", "근본 원인", } + +// === 语义路由 === + +// complexTaskPatterns 是一组描述"复杂任务"的语义模式, 用于语义路由。 +// 当 ONNX 嵌入器可用时, 用户输入与这些模式的语义相似度超过阈值即路由到 pro。 +// 相比关键词匹配, 语义匹配能覆盖同义词、近义表达和跨语言描述。 +var complexTaskPatterns = []string{ + "重构代码架构", + "分析系统依赖关系", + "设计模块接口和抽象", + "排查性能瓶颈和优化", + "review 代码安全性和质量", + "多文件跨模块重构", + "架构设计和技术选型", + "分析调用链和依赖图", + "设计数据库表结构和查询", + "修复复杂 bug 分析根因", + "编写单元测试和集成测试", + "大段代码修改和重构", + "设计 API 接口规范", + "分析系统设计文档", + "优化算法和数据结构", +} + +// patternVecs 缓存复杂任务模式的语义向量。 +var patternVecs map[string]map[string]float64 +var patternVecsOnce sync.Once + +// initPatternVecs 预计算复杂任务模式的语义向量。 +func initPatternVecs(embedder Embedder) { + patternVecsOnce.Do(func() { + if embedder == nil { + return + } + patternVecs = make(map[string]map[string]float64, len(complexTaskPatterns)) + for _, p := range complexTaskPatterns { + patternVecs[p] = embedder.Embed(p) + } + }) +} + +// RouteBySemantic 通过语义匹配判断是否需要 pro 模型。 +// 当用户输入与任意"复杂任务"模式的语义相似度 ≥ complexTaskSimThreshold 时返回 "pro"。 +// embedder 为 nil 时返回空串(调用方决定回退)。 +const complexTaskSimThreshold = 0.6 + +func RouteBySemantic(userMsg string, embedder Embedder) string { + if embedder == nil { + return "" + } + initPatternVecs(embedder) + userVec := embedder.Embed(userMsg) + if len(userVec) == 0 { + return "" + } + for _, p := range complexTaskPatterns { + pVec := patternVecs[p] + if len(pVec) == 0 { + continue + } + sim := cosineSimilarity(userVec, pVec) + if sim >= complexTaskSimThreshold { + return "pro" + } + } + return "" +} + +// === 上下文感知路由 === + +// RouteWithContext 在关键词路由基础上加入会话上下文感知 + 语义匹配。 +// 当启用 TopicGraph 且 ONNX 嵌入器时,短消息可能延续当前话题的模型选择。 +// 参数: +// userMsg - 用户当前输入 +// tg - 主题追踪图(nil 时退化为纯关键词路由) +// 返回 "flash" 或 "pro"。 +func RouteWithContext(userMsg string, tg *TopicGraph) string { + // 1. 当前规则: 关键词 + 长度 + role := RouteByKeyword(userMsg) + if role == "pro" { + return role + } + + // 2. 语义匹配(仅在 ONNX 嵌入器可用时) + if tg != nil && tg.embedder != nil { + if r := RouteBySemantic(userMsg, tg.embedder); r == "pro" { + return r + } + } + + // 3. 上下文感知(仅在 TopicGraph 可用且会话已形成稳定关注点时) + if tg == nil || !tg.FocusEstablished() { + return role + } + + sim := tg.SimilarityToSession(userMsg) + + // 短消息 + 与会话重心相似度高 → 延续当前话题, 沿用上轮模型 + if len([]rune(userMsg)) < 100 && sim > 0.3 && tg.LastModelRole != "" { + return tg.LastModelRole + } + + // 完全偏离 → 新话题, flash 起步 + if sim < 0.02 { + return "flash" + } + + return role +} diff --git a/agent/llm.go b/agent/llm.go index 63486ad..f002d42 100644 --- a/agent/llm.go +++ b/agent/llm.go @@ -711,7 +711,7 @@ func StartStream( len(convo) > 0 && convo[0].Role == "system" { hist := convo[1:] ch <- CompactingMsg{} // 先亮状态行:下面这行最长会卡 10 分钟,期间不吐任何 token - sum, cutIdx, turns, cerr := RunCompression(convo[0].Content, MarshalToolSpecs(toolSpecs), hist, currentEntry, ctxWin) + sum, cutIdx, turns, cerr := RunCompression(convo[0].Content, MarshalToolSpecs(toolSpecs), hist, currentEntry, ctxWin, "") if cerr != nil { // 按失败类型分流:轮数不足是本轮结构性不可恢复(轮数恒定,重试注定再失败)→ 永久关, // 不刷屏;瞬时失败(超时/网络)→ 冷却 compactRetryCooldown 圈后重试(见状态变量注释)。 diff --git a/agent/ort_engine.go b/agent/ort_engine.go new file mode 100644 index 0000000..ee25fb5 --- /dev/null +++ b/agent/ort_engine.go @@ -0,0 +1,84 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + + ort "github.com/getcharzp/onnxruntime_purego" +) + +// ortEngine 共享的 ONNX Runtime 引擎(单例)。 +// OCR 和 Embedder 共用同一个引擎实例, 避免重复加载 ONNX Runtime 共享库。 +var ( + ortEngineOnce sync.Once + ortEngineInst *ortEngine + ortEngineErr error +) + +type ortEngine struct { + engine *ort.Engine + libDir string // 共享库所在目录 +} + +// GetORTEngine 返回共享的 ONNX Runtime 引擎。 +// libDir 是 ONNX Runtime 共享库所在目录(通常与 OCR 共享库同目录)。 +// 首次调用时初始化引擎, 后续调用返回同一实例。 +func GetORTEngine(libDir string) (*ortEngine, error) { + ortEngineOnce.Do(func() { + ortEngineInst, ortEngineErr = newORTEngine(libDir) + }) + return ortEngineInst, ortEngineErr +} + +func newORTEngine(libDir string) (*ortEngine, error) { + libPath := filepath.Join(libDir, ortLibName()) + if _, err := os.Stat(libPath); os.IsNotExist(err) { + return nil, fmt.Errorf("ONNX Runtime 共享库不存在: %s (请确保 OCR 已初始化)", libPath) + } + engine, err := ort.NewEngine(libPath) + if err != nil { + return nil, fmt.Errorf("初始化 ONNX Runtime 失败: %w", err) + } + return &ortEngine{engine: engine, libDir: libDir}, nil +} + +// NewSession 创建 ONNX 推理会话。 +func (e *ortEngine) NewSession(modelPath string, threads int) (*ort.Session, error) { + opts, err := e.engine.NewSessionOptions() + if err != nil { + return nil, err + } + defer opts.Destroy() + + if threads <= 0 { + threads = runtime.NumCPU() / 2 + if threads < 1 { + threads = 1 + } + } + _ = opts.SetIntraOpNumThreads(int32(threads)) + _ = opts.SetCpuMemArena(true) + + session, err := e.engine.NewSession(modelPath, opts) + if err != nil { + return nil, fmt.Errorf("加载模型失败: %w", err) + } + return session, nil +} + +// ortLibName 返回当前平台的 ONNX Runtime 共享库文件名。 +func ortLibName() string { + switch runtime.GOOS { + case "linux": + return "onnxruntime_amd64.so" + case "darwin": + return "onnxruntime_arm64.dylib" + case "windows": + return "onnxruntime.dll" + default: + return "onnxruntime_amd64.so" + } +} diff --git a/agent/segmenter.go b/agent/segmenter.go new file mode 100644 index 0000000..402ab8c --- /dev/null +++ b/agent/segmenter.go @@ -0,0 +1,99 @@ +package agent + +import "strings" + +// === 分词器 === +// +// 分词器默认不启用。在 ~/.deepx/segmenter.yaml 中设置 language: zh 启用中文词典分词。 +// 启用后首次使用自动下载词典文件到 ~/.deepx/segmenter/。 +// 未启用时, TopicGraph 不会被创建, 无主题追踪。 + +// Segmenter 是分词器接口。每种语言一个实现, 纯本地运行, 零 LLM 调用。 +type Segmenter interface { + Segment(text string) []string + Name() string +} + +// NewSegmenter 按语言类型创建分词器实例。 +// cacheDir 是词典文件缓存目录。 +// t 为空时返回 nil, 表示不启用分词器。 +func NewSegmenter(t string, cacheDir string) (Segmenter, error) { + switch t { + case "zh": + return newDictSegmenter(cacheDir) + default: + return nil, nil + } +} + +// tokenize 是通用内置分词, 仅用于测试。 +// 生产代码中 TopicGraph 通过 Segmenter 接口使用词典分词器。 +func tokenize(text string) []string { + text = strings.ToLower(strings.TrimSpace(text)) + if text == "" { + return nil + } + + var tokens []string + var buf strings.Builder + + flush := func() { + if buf.Len() > 0 { + w := buf.String() + if len(w) > 1 || isSignificant(w) { + tokens = append(tokens, w) + } + buf.Reset() + } + } + + var cjkBuf []rune + flushCJK := func() { + if len(cjkBuf) > 0 { + for i := 0; i+1 < len(cjkBuf); i += 2 { + tokens = append(tokens, string(cjkBuf[i:i+2])) + } + if len(cjkBuf)%2 != 0 { + tokens = append(tokens, string(cjkBuf[len(cjkBuf)-1])) + } + cjkBuf = cjkBuf[:0] + } + } + + runes := []rune(text) + for _, r := range runes { + if isCJK(r) { + flush() + cjkBuf = append(cjkBuf, r) + } else if isLetterOrDigit(r) { + flushCJK() + buf.WriteRune(r) + } else { + flushCJK() + flush() + } + } + flushCJK() + flush() + return tokens +} + +func isCJK(r rune) bool { + return (r >= 0x4E00 && r <= 0x9FFF) || + (r >= 0x3400 && r <= 0x4DBF) || + (r >= 0x3040 && r <= 0x309F) || + (r >= 0x30A0 && r <= 0x30FF) || + (r >= 0xAC00 && r <= 0xD7AF) +} + +func isLetterOrDigit(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') +} + +func isSignificant(s string) bool { + if len(s) != 1 { + return true + } + r := rune(s[0]) + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') +} diff --git a/agent/segmenter_dict.go b/agent/segmenter_dict.go new file mode 100644 index 0000000..5a26312 --- /dev/null +++ b/agent/segmenter_dict.go @@ -0,0 +1,376 @@ +package agent + +import ( + "bufio" + "compress/gzip" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// === 词典分词器 (Forward Maximum Matching) === +// +// 纯 Go 实现, 零外部依赖。使用词典文件进行正向最大匹配分词。 +// 词典文件首次使用时从配置的 URL 下载, 缓存到本地。 +// +// 词典格式: 每行一个词, 可选频率 +// 词 +// 词 频率 +// +// 频率越高, 匹配优先级越高(同长度时)。 + +// dictSegmenter 使用词典进行正向最大匹配。 +type dictSegmenter struct { + mu sync.RWMutex + words map[string]wordEntry // 词 → 频率+词性 + maxLen int // 词典中最长词的字符数 + ready bool +} + +// wordEntry 词典条目: 频率 + 词性标签(POS tag)。 +type wordEntry struct { + freq int + pos string +} + +// DefaultDictURL 是默认词典下载地址(MIT 许可的 jieba 词典)。 +// 使用 jsdelivr CDN(国内可用, 无需翻墙)。 +const DefaultDictURL = "https://cdn.jsdelivr.net/gh/fxsjy/jieba@master/jieba/dict.txt" + +// dictFileName 词典文件名。 +const dictFileName = "segmenter_dict.txt.gz" + +// newDictSegmenter 创建词典分词器。首次调用会检查缓存目录, +// 若词典文件不存在则从 DefaultDictURL 下载。 +func newDictSegmenter(cacheDir string) (*dictSegmenter, error) { + d := &dictSegmenter{ + words: make(map[string]wordEntry), + } + + dictPath := filepath.Join(cacheDir, dictFileName) + if _, err := os.Stat(dictPath); os.IsNotExist(err) { + // 词典不存在, 尝试下载 + if err := d.downloadDefaultDict(cacheDir); err != nil { + return nil, fmt.Errorf("下载词典失败: %w\n可手动下载后放入 %s", err, dictPath) + } + } + + if err := d.loadDict(dictPath); err != nil { + return nil, fmt.Errorf("加载词典失败: %w", err) + } + // 补充缺失的常见词(如 jieba 默认词典未收录的"会话"等) + d.loadSupplement() + return d, nil +} + +// loadSupplement 加载内置补充词典, 收录 jieba 默认词典未收录的常见中文词。 +// 这些词在 FMM 中会被拆为单字, 补充后可用完整词匹配。 +func (d *dictSegmenter) loadSupplement() { + // 格式: word freq pos + supplement := []string{ + "会话 100 n", + "提示词 50 n", + "可以 50000 v", + "并且 10000 c", + "或者 10000 c", + "虽然 5000 c", + "因为 5000 c", + "所以 5000 c", + "如果 5000 c", + "但是 5000 c", + "然后 5000 c", + "而且 5000 c", + "不仅 5000 c", + "还是 5000 c", + "只是 5000 c", + "要么 5000 c", + "就是 5000 d", + "已经 50000 d", + "没有 50000 v", + "不是 50000 v", + "不是 50000 v", + } + for _, line := range supplement { + parts := strings.Fields(line) + if len(parts) < 2 { + continue + } + word := parts[0] + freq := 1 + pos := "" + fmt.Sscanf(parts[1], "%d", &freq) + if len(parts) >= 3 { + pos = parts[2] + } + if _, exists := d.words[word]; !exists { + d.words[word] = wordEntry{freq: freq, pos: pos} + runes := []rune(word) + if len(runes) > d.maxLen { + d.maxLen = len(runes) + } + } + } +} + +// downloadDefaultDict 从默认 URL 下载并压缩词典。 +func (d *dictSegmenter) downloadDefaultDict(cacheDir string) error { + if err := os.MkdirAll(cacheDir, 0700); err != nil { + return err + } + + // 从 CDN 下载(MIT 许可的 jieba 词典)。 + url := DefaultDictURL + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("无法下载词典 %s: %w", url, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("下载词典返回 %s", resp.Status) + } + + // 读取并压缩保存 + tmpPath := filepath.Join(cacheDir, dictFileName+".tmp") + f, err := os.Create(tmpPath) + if err != nil { + return err + } + defer f.Close() + + gw := gzip.NewWriter(f) + // 限制下载大小: 词典不超过 32MB + if _, err := io.Copy(gw, io.LimitReader(resp.Body, 32<<20)); err != nil { + os.Remove(tmpPath) + return err + } + gw.Close() + f.Close() + + os.Rename(tmpPath, filepath.Join(cacheDir, dictFileName)) + return nil +} + +// loadDict 从 gzip 压缩的词典文件加载词表。 +func (d *dictSegmenter) loadDict(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + gr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gr.Close() + + scanner := bufio.NewScanner(gr) + maxLen := 0 + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Fields(line) + if len(parts) == 0 { + continue + } + word := parts[0] + freq := 1 + pos := "" + if len(parts) >= 2 { + fmt.Sscanf(parts[1], "%d", &freq) + } + if len(parts) >= 3 { + pos = parts[2] + } + if freq <= 0 { + freq = 1 + } + runes := []rune(word) + if len(runes) > maxLen { + maxLen = len(runes) + } + d.words[word] = wordEntry{freq: freq, pos: pos} + } + + if err := scanner.Err(); err != nil { + return err + } + if len(d.words) == 0 { + return errors.New("词典为空") + } + d.maxLen = maxLen + d.ready = true + return nil +} + +// Segment 对文本进行分词。 +func (d *dictSegmenter) Segment(text string) []string { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + + d.mu.RLock() + ready := d.ready + d.mu.RUnlock() + if !ready { + return nil + } + + var tokens []string + runes := []rune(text) + i := 0 + for i < len(runes) { + if isCJK(runes[i]) { + // CJK 部分: 正向最大匹配 + tok, consumed := d.matchLongest(runes, i) + // POS 过滤: 跳过连词/介词/助词/代词/叹词/拟声词等虚词 + if !isFunctionPOS(d.posOf(tok)) { + tokens = append(tokens, tok) + } + i += consumed + } else if isLetterOrDigit(runes[i]) { + // 拉丁/数字: 连续读入 + var buf strings.Builder + for i < len(runes) && isLetterOrDigit(runes[i]) { + buf.WriteRune(runes[i]) + i++ + } + tokens = append(tokens, buf.String()) + } else { + i++ + } + } + // 后处理: 合并连续的单 CJK 字符为二元组(补偿词典未收录的复合词, 如"会话"→"会话") + tokens = mergeSingleCJK(tokens, d.words) + return tokens +} + +// mergeSingleCJK 合并 tokens 中连续的单 CJK 字符为二元组。 +// 词典分词中若某复合词(如"会话")未收录但单字存在, FMM 会输出["会","话"], +// 此函数尝试将其合并。 +// 为避免误合并(如"词可"不应合并为"词可"), 仅当合并后的词对 +// 有一定的语义合理性时才合并: 两个单字都在词典中且合并后词不在词典中时, +// 检查是否属于常见复合词模式, 否则不合并以保留单字。 +func mergeSingleCJK(tokens []string, words map[string]wordEntry) []string { + if len(tokens) < 2 || words == nil { + return tokens + } + out := make([]string, 0, len(tokens)) + i := 0 + for i < len(tokens) { + r := []rune(tokens[i]) + if len(r) == 1 && isCJK(r[0]) && i+1 < len(tokens) { + r2 := []rune(tokens[i+1]) + if len(r2) == 1 && isCJK(r2[0]) { + combined := string(r[0]) + string(r2[0]) + // 合并后的词在词典中已有 → 优先使用词典词 + // 合并后的词不在词典中, 但两个单字都在词典中 → 各自是独立词, 不合并 + // 合并后的词不在词典中, 且至少一个单字不在词典中 → 合并 + _, hasFirst := words[tokens[i]] + _, hasSecond := words[tokens[i+1]] + _, hasCombined := words[combined] + if hasCombined { + out = append(out, combined) + i += 2 + continue + } + if !hasFirst || !hasSecond { + // 至少一个单字不是独立词 → 合并 + out = append(out, combined) + i += 2 + continue + } + // 两个单字都是独立词典词, 且合并后不在词典中 → 各自保留 + out = append(out, tokens[i]) + i++ + continue + } + } + out = append(out, tokens[i]) + i++ + } + return out +} + +// matchLongest 从 runes[pos] 开始, 在词典中查找最长匹配词。 +func (d *dictSegmenter) matchLongest(runes []rune, pos int) (string, int) { + d.mu.RLock() + defer d.mu.RUnlock() + + maxLen := d.maxLen + if maxLen <= 0 { + maxLen = 4 + } + remaining := len(runes) - pos + if maxLen > remaining { + maxLen = remaining + } + + // 从最长开始尝试匹配 + bestWord := "" + bestFreq := 0 + for length := maxLen; length >= 1; length-- { + if pos+length > len(runes) { + continue + } + candidate := string(runes[pos : pos+length]) + if entry, ok := d.words[candidate]; ok { + bestLen := len([]rune(bestWord)) + if length > bestLen || (length == bestLen && entry.freq > bestFreq) { + bestWord = candidate + bestFreq = entry.freq + } + } + } + + if bestWord != "" { + return bestWord, len([]rune(bestWord)) + } + // 未匹配: 返回单字符 + return string(runes[pos]), 1 +} + +// Name 返回分词器名称。 +func (d *dictSegmenter) Name() string { + d.mu.RLock() + cnt := len(d.words) + d.mu.RUnlock() + return fmt.Sprintf("dict(%d词)", cnt) +} + +// posOf 返回词的词性标签, 未找到返回空。 +func (d *dictSegmenter) posOf(word string) string { + if entry, ok := d.words[word]; ok { + return entry.pos + } + return "" +} + +// isFunctionPOS 判断词性标签是否为虚词/功能词, 不应作为关键词。 +// 基于 jieba 词性标注体系: +// c=连词, p=介词, u=助词, r=代词, e=叹词, o=拟声词, f=方位词, +// w=标点, x=非语素, y=语气词, h=前缀, k=后缀, q=量词, g=语素 +func isFunctionPOS(pos string) bool { + if pos == "" { + return false + } + switch pos[0] { + case 'c', 'p', 'u', 'r', 'e', 'o', 'f', 'w', 'x', 'y', 'h', 'k', 'q', 'g': + return true + } + return false +} diff --git a/agent/topic_tracker.go b/agent/topic_tracker.go new file mode 100644 index 0000000..da38c72 --- /dev/null +++ b/agent/topic_tracker.go @@ -0,0 +1,514 @@ +package agent + +import ( + "fmt" + "math" + "sort" + "strings" + "unicode" +) + +// === 本地主题追踪 (Phase 1) === +// +// TopicTracker 用纯本地算法(零 LLM 调用)追踪对话主题的演化。 +// 每轮 user 消息调用 TrackMessage(),自动分配或创建主题。 +// 产出 TopicGraph 供 Phase 2 策略性压缩使用。 +// +// 算法: +// - 分词: 拉丁文本按空白/标点切词; CJK 文本按字符二元组(bigram) +// - 关键词提取: TF-IDF, 每个主题保留 top 5 关键词 +// - 主题匹配: 新消息关键词向量与已有主题做余弦相似度 +// - 阈值: 相似度 < 0.15 则创建新主题 + +// Topic 表示一个对话主题。 +type Topic struct { + ID int // 唯一标识 + Keywords []string // top 5 关键词 + Vector map[string]float64 // TF-IDF 向量 + CreateAt int // 首次出现的消息索引(在 history 中) + LastAt int // 最后一次出现的消息索引 + Files map[string]bool // 该主题下涉及的文件路径 +} + +// TopicGraph 是主题追踪的完整状态, 可在 session 中序列化。 +type TopicGraph struct { + Topics []Topic + MsgTopics []int // msgIdx → topicIdx (在 history 中的索引) + DocFreq map[string]int // 文档频率(用于 TF-IDF 回退) + TotalDocs int + NextID int + + segmenter Segmenter // 分词器, 不序列化 + embedder Embedder // 嵌入器(TF-IDF/ONNX), 不序列化 + + LastModelRole string // 上一轮使用的模型("flash"或"pro"), 不序列化 +} + +// NewTopicGraph 创建空的 topic graph。 +// seg 为分词器, nil 时不启用主题追踪。 +// emb 为嵌入器, nil 时使用默认 TF-IDF。 +func NewTopicGraph(seg Segmenter, emb Embedder) *TopicGraph { + tg := &TopicGraph{ + DocFreq: make(map[string]int), + embedder: emb, + } + if emb == nil { + tg.embedder = newTFIDFEmbedder() + } + if seg != nil { + tg.segmenter = seg; tg.embedder = tg.embedder + } + return tg +} + +// === 分词器集成 === + +// Segment 使用 TopicGraph 绑定的分词器对文本分词。 +// 分词器必须已配置; 仅在 segmenter 启用时 TopicGraph 才会被创建。 +func (tg *TopicGraph) Segment(text string) []string { + if tg.segmenter != nil { + return tg.segmenter.Segment(text) + } + return nil +} + +// === TF-IDF === + +// extractTFIDF 计算 token 列表的 TF-IDF 向量。 +func (tg *TopicGraph) extractTFIDF(tokens []string) map[string]float64 { + if len(tokens) == 0 { + return nil + } + + tf := make(map[string]int) + for _, t := range tokens { + tf[t]++ + } + + vec := make(map[string]float64, len(tf)) + for term, count := range tf { + tfVal := float64(count) / float64(len(tokens)) + df := tg.DocFreq[term] + 1 // +1 平滑 + idf := math.Log(float64(tg.TotalDocs+1) / float64(df)) + vec[term] = tfVal * idf + } + return vec +} + +// updateDocFreq 用新出现的 token 更新全局文档频率。 +func (tg *TopicGraph) updateDocFreq(tokens []string) { + seen := make(map[string]bool, len(tokens)) + for _, t := range tokens { + if seen[t] { + continue + } + seen[t] = true + tg.DocFreq[t]++ + } + tg.TotalDocs++ +} + +// === 相似度 === + +// cosineSimilarity 计算两个向量的余弦相似度。 +func cosineSimilarity(a, b map[string]float64) float64 { + if len(a) == 0 || len(b) == 0 { + return 0 + } + var dot, normA, normB float64 + for k, va := range a { + normA += va * va + if vb, ok := b[k]; ok { + dot += va * vb + } + } + for _, vb := range b { + normB += vb * vb + } + if normA == 0 || normB == 0 { + return 0 + } + return dot / (math.Sqrt(normA) * math.Sqrt(normB)) +} + +// === 主题管理 === + +// newTopicThreshold: 余弦相似度低于此值则创建新主题。 +// 0.15 经初步测试——太高的阈值会碎片化(每个消息一个新主题), +// 太低会把不相关的内容合并。可根据实际使用调整。 +const newTopicThreshold = 0.15 + +// topKeywordCount 是每个主题保留的关键词数。 +const topKeywordCount = 5 + +// TrackMessage 处理一条 user 消息, 返回归属的主题索引和是否新建了主题。 +// msgIdx 是消息在 history 中的位置, 用于 CreateAt/LastAt 追踪。 +func (tg *TopicGraph) TrackMessage(content string, msgIdx int) (topicIdx int, isNew bool) { + tokens := tg.Segment(content) + if len(tokens) == 0 { + // 空消息: 归入最近主题 + if len(tg.Topics) > 0 { + last := len(tg.Topics) - 1 + tg.Topics[last].LastAt = msgIdx + tg.MsgTopics = append(tg.MsgTopics, last) + return last, false + } + return 0, false + } + + vec := tg.embedder.Embed(content) + if len(vec) == 0 { + vec = tg.extractTFIDF(tokens) // 回退: embedder 未就绪时用 TF-IDF + } + + // 查找最相似的主题 + bestTopic := -1 + bestScore := 0.0 + for i := range tg.Topics { + score := cosineSimilarity(vec, tg.Topics[i].Vector) + if score > bestScore { + bestScore = score + bestTopic = i + } + } + + if bestTopic == -1 || bestScore < newTopicThreshold { + // 创建新主题 + topic := Topic{ + ID: tg.NextID, + Keywords: topKeywords(vec, topKeywordCount), + Vector: vec, + CreateAt: msgIdx, + LastAt: msgIdx, + Files: make(map[string]bool), + } + tg.NextID++ + tg.Topics = append(tg.Topics, topic) + idx := len(tg.Topics) - 1 + tg.MsgTopics = append(tg.MsgTopics, idx) + return idx, true + } + + // 合并到已有主题 + tg.Topics[bestTopic].LastAt = msgIdx + tg.Topics[bestTopic].Vector = mergeVectors(tg.Topics[bestTopic].Vector, vec, 0.3) + tg.Topics[bestTopic].Keywords = topKeywords(tg.Topics[bestTopic].Vector, topKeywordCount) + tg.MsgTopics = append(tg.MsgTopics, bestTopic) + return bestTopic, false +} + +// TrackFile 记录某个主题下涉及的文件路径。 +func (tg *TopicGraph) TrackFile(topicIdx int, path string) { + if topicIdx < 0 || topicIdx >= len(tg.Topics) { + return + } + tg.Topics[topicIdx].Files[path] = true +} + +// TopicOf 返回消息索引对应的主题索引, -1 表示未追踪。 +func (tg *TopicGraph) TopicOf(msgIdx int) int { + if msgIdx < 0 || msgIdx >= len(tg.MsgTopics) { + return -1 + } + return tg.MsgTopics[msgIdx] +} + +// CurrentTopic 返回最近一次消息所属的主题索引。 +func (tg *TopicGraph) CurrentTopic() int { + if len(tg.MsgTopics) == 0 { + return -1 + } + return tg.MsgTopics[len(tg.MsgTopics)-1] +} + +// TopicKeywords 返回指定主题的关键词列表。 +func (tg *TopicGraph) TopicKeywords(topicIdx int) []string { + if topicIdx < 0 || topicIdx >= len(tg.Topics) { + return nil + } + return tg.Topics[topicIdx].Keywords +} + +// === 辅助函数 === + +// topKeywords 从 TF-IDF 向量中取 top N 关键词。 +func topKeywords(vec map[string]float64, n int) []string { + type kv struct { + k string + v float64 + } + pairs := make([]kv, 0, len(vec)) + for k, v := range vec { + pairs = append(pairs, kv{k, v}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].v != pairs[j].v { + return pairs[i].v > pairs[j].v // 高分优先 + } + return len([]rune(pairs[i].k)) > len([]rune(pairs[j].k)) // 等分时, 长词优先 + }) + if n > len(pairs) { + n = len(pairs) + } + out := make([]string, n) + for i := 0; i < n; i++ { + out[i] = pairs[i].k + } + return out +} + +// mergeVectors 将 src 向量按权重 rate 合并到 dst。 +// rate=0.3 表示新消息占 30% 权重, 旧主题向量占 70%。 +func mergeVectors(dst, src map[string]float64, rate float64) map[string]float64 { + if dst == nil { + dst = make(map[string]float64) + } + for k, v := range dst { + dst[k] = v * (1 - rate) + } + for k, v := range src { + dst[k] += v * rate + } + return dst +} + +// Rebuild 从 history 重建完整的 TopicGraph。 +// 用于会话恢复时从 gob 加载的历史重建主题追踪状态。 +func (tg *TopicGraph) Rebuild(history []ChatMessage) { + seg := tg.segmenter + emb := tg.embedder + *tg = *NewTopicGraph(nil, emb) + tg.segmenter = seg + + for i, msg := range history { + if msg.Role != "user" { + tg.MsgTopics = append(tg.MsgTopics, -1) // 非 user 消息占位 + continue + } + tg.TrackMessage(msg.Content, i) + // 从 assistant 回复中提取文件引用 + if i+1 < len(history) && history[i+1].Role == "assistant" { + topicIdx := tg.CurrentTopic() + for _, path := range extractFileRefs(history[i+1].Content) { + tg.TrackFile(topicIdx, path) + } + } + } +} + +// extractFileRefs 从 assistant 内容中提取文件路径引用。 +func extractFileRefs(content string) []string { + var refs []string + // 匹配常见文件引用模式: `/path/to/file.go`, `file.go`, `tui/model.go` + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + // 简单启发式: 包含常见代码文件扩展名的路径 + for _, ext := range []string{".go", ".py", ".js", ".ts", ".rs", ".java", ".rb", ".yaml", ".json", ".md", ".html", ".css"} { + if idx := strings.Index(line, ext); idx >= 0 { + // 向前搜索路径开始 + start := idx + for start > 0 && (unicode.IsLetter(rune(line[start-1])) || unicode.IsDigit(rune(line[start-1])) || + line[start-1] == '/' || line[start-1] == '.' || line[start-1] == '_' || line[start-1] == '-') { + start-- + } + ref := strings.TrimSpace(line[start : idx+len(ext)]) + if strings.Contains(ref, ".") && !strings.HasPrefix(ref, "http") { + refs = append(refs, ref) + break + } + } + } + } + return refs +} + +// === 话题切换检测 === + +// TopicSwitched 判断当前消息是否偏离了会话的整体上下文。 +// 通过计算当前话题向量与会话重心向量(全部话题的加权平均)的余弦相似度, +// 而非仅与主导话题比对, 更准确地反映会话的整体语义方向。 +// 会话总消息数 ≥ minMsgs 且当前话题 ≥ 2 条消息, +// 且当前话题与会话重心语义不相关(余弦相似度 < extensionSim)时, +// 认为发生了有意义的话题切换(而非主题扩展)。 +// 返回 (是否切换, 会话重心关键词, 新话题关键词)。 +func (tg *TopicGraph) TopicSwitched(minMsgs int) (switched bool, oldKW, newKW []string) { + if len(tg.Topics) < 2 { + return false, nil, nil + } + cur := tg.CurrentTopic() + if cur < 0 { + return false, nil, nil + } + // 当前话题至少要有 2 条消息才认为是"新方向", 而非临时插话 + curMsgs := tg.Topics[cur].LastAt - tg.Topics[cur].CreateAt + 1 + if curMsgs < 2 { + return false, nil, nil + } + // 会话总消息数不足 → 上下文还不够形成"会话重心" + totalMsgs := 0 + for _, t := range tg.Topics { + totalMsgs += t.LastAt - t.CreateAt + 1 + } + if totalMsgs < minMsgs { + return false, nil, nil + } + // 计算会话重心向量(全部话题的加权平均, 权重 = 消息数) + centroid := tg.sessionCentroid() + // 当前话题与重心向量的语义相似度 + sim := cosineSimilarity(tg.Topics[cur].Vector, centroid) + if sim >= topicExtensionSim { + return false, nil, nil // 主题扩展, 不提示 + } + // 提取重心方向的关键词: 用消息数最多的主导话题 + dominant := -1 + dominantMsgs := 0 + for i := range tg.Topics { + if i == cur { + continue + } + msgs := tg.Topics[i].LastAt - tg.Topics[i].CreateAt + 1 + if msgs > dominantMsgs { + dominantMsgs = msgs + dominant = i + } + } + if dominant < 0 { + return false, nil, nil + } + return true, tg.Topics[dominant].Keywords, tg.Topics[cur].Keywords +} + +// sessionCentroid 计算会话重心向量: 全部话题的加权平均 TF-IDF 向量。 +// 权重 = 消息数, 话题消息越多, 对该向量的贡献越大。 +func (tg *TopicGraph) sessionCentroid() map[string]float64 { + centroid := make(map[string]float64) + totalMsgs := 0 + for _, t := range tg.Topics { + msgs := t.LastAt - t.CreateAt + 1 + totalMsgs += msgs + weight := float64(msgs) + for k, v := range t.Vector { + centroid[k] += v * weight + } + } + if totalMsgs > 0 { + for k := range centroid { + centroid[k] /= float64(totalMsgs) + } + } + return centroid +} + +// topicExtensionSim 是"主题扩展"的相似度阈值。 +// 新话题与主导话题的余弦相似度 ≥ 此值时, 视为同一会话下的主题扩展, 不提示创建新会话。 +// 设为 0.05, 介于"完全无关(0.0)"和"相似话题(0.10+)"之间。 +const topicExtensionSim = 0.05 + +// RelevanceTo 返回当前消息与 msgIdx 处消息的主题相关性(0~1)。 +// 值越大表示越相关, <0.15 表示不同话题。 +func (tg *TopicGraph) RelevanceTo(msgIdx int) float64 { + cur := tg.CurrentTopic() + if cur < 0 || msgIdx < 0 || msgIdx >= len(tg.MsgTopics) { + return 0 + } + target := tg.MsgTopics[msgIdx] + if target < 0 || target == cur { + if target == cur { + return 1.0 // 同话题 + } + return 0 + } + return cosineSimilarity(tg.Topics[cur].Vector, tg.Topics[target].Vector) +} + +// SessionFocus 返回当前会话侧重点的摘要描述。 +// 基于当前话题的关键词、消息数和涉及文件生成。 +func (tg *TopicGraph) SessionFocus() string { + cur := tg.CurrentTopic() + if cur < 0 || cur >= len(tg.Topics) { + return "" + } + t := tg.Topics[cur] + kw := t.Keywords + if len(kw) == 0 { + return "" + } + label := strings.Join(kw, " ") + msgs := t.LastAt - t.CreateAt + 1 + if msgs > 1 { + label += fmt.Sprintf(" (%d轮)", msgs) + } + if len(t.Files) > 0 { + files := make([]string, 0, len(t.Files)) + for f := range t.Files { + files = append(files, f) + } + sort.Strings(files) + if len(files) > 3 { + label += fmt.Sprintf(" +%d文件", len(files)) + } else { + label += " " + strings.Join(files, " ") + } + } + return label +} + +// FocusChanged 判断会话侧重点是否发生有意义的变化。 +// 当当前话题消息数达到 minMsgs, 且与上次焦点不同时返回 true。 +func (tg *TopicGraph) FocusChanged(minMsgs int, lastFocusID *int) (changed bool, focus string) { + cur := tg.CurrentTopic() + if cur < 0 { + return false, "" + } + msgs := tg.Topics[cur].LastAt - tg.Topics[cur].CreateAt + 1 + if msgs < minMsgs { + return false, "" + } + if *lastFocusID == cur { + return false, "" + } + *lastFocusID = cur + return true, tg.SessionFocus() +} + +// EmbedderName 返回嵌入器名称, 用于 UI 判断。 +func (tg *TopicGraph) EmbedderName() string { + if tg.embedder != nil { + return tg.embedder.Name() + } + return "tfidf" +} + +// === 发送前偏离检测 === + +// DriftDetectThreshold 是"发送前偏离检测"的相似度阈值。 +// 用户输入与会话重心的相似度低于此值时, 提示用户确认是否发送。 +// 设为 0.02, 比 topicExtensionSim(0.05) 更严格, 只拦截明显偏离的输入。 +const DriftDetectThreshold = 0.02 + +// FocusEstablished 判断会话是否已形成稳定的关注点(≥ 3 条消息)。 +func (tg *TopicGraph) FocusEstablished() bool { + total := 0 + for _, t := range tg.Topics { + total += t.LastAt - t.CreateAt + 1 + } + return total >= 3 +} + +// SimilarityToSession 返回文本与会话整体上下文的语义相似度(0~1)。 +// 使用 cosineSimilarity 比较文本向量与会话重心向量。 +func (tg *TopicGraph) SimilarityToSession(text string) float64 { + vec := tg.embedder.Embed(text) + if len(vec) == 0 { + vec = tg.extractTFIDF(tg.Segment(text)) + } + if len(vec) == 0 { + return 0 + } + centroid := tg.sessionCentroid() + if len(centroid) == 0 { + return 1.0 // 无重心 → 允许任意输入 + } + return cosineSimilarity(vec, centroid) +} diff --git a/agent/topic_tracker_test.go b/agent/topic_tracker_test.go new file mode 100644 index 0000000..05c3471 --- /dev/null +++ b/agent/topic_tracker_test.go @@ -0,0 +1,312 @@ +package agent + +import ( + "testing" +) + +// testSeg 是测试用分词器, 使用 tokenize() 实现。 +// 真实环境中 TopicGraph 使用词典分词器。 +type testSeg struct{} + +func (s *testSeg) Segment(text string) []string { return tokenize(text) } +func (s *testSeg) Name() string { return "test" } + +// newTestGraph 创建带测试分词器的 TopicGraph(中文虚词过滤)。 +func newTestGraph() *TopicGraph { + return NewTopicGraph(&testSeg{}, nil) +} + +func TestTokenizeLatin(t *testing.T) { + tokens := tokenize("Hello World! This is a test.") + if len(tokens) < 4 { + t.Fatalf("expected at least 4 tokens, got %d: %v", len(tokens), tokens) + } + for _, tok := range tokens { + if tok == "" { + t.Error("unexpected empty token") + } + } +} + +func TestTokenizeCJK(t *testing.T) { + tokens := tokenize("你好世界") + if len(tokens) < 2 { + t.Fatalf("expected at least 2 tokens, got %d: %v", len(tokens), tokens) + } +} + +func TestTokenizeMixed(t *testing.T) { + tokens := tokenize("修改 model.go 文件中的 max_tokens 配置") + if len(tokens) < 3 { + t.Fatalf("expected at least 3 tokens, got %d: %v", len(tokens), tokens) + } +} + +func TestTokenizeEmpty(t *testing.T) { + tokens := tokenize("") + if len(tokens) != 0 { + t.Fatalf("expected 0 tokens, got %d", len(tokens)) + } +} + +func TestTokenizeSingleChar(t *testing.T) { + // 单字符标点应被过滤; 单字母保留(可能是变量名) + tokens := tokenize(".") + if len(tokens) != 0 { + t.Fatalf("single punctuation should be filtered, got %d: %v", len(tokens), tokens) + } +} + +func TestTrackMessageSameTopic(t *testing.T) { + tg := newTestGraph() + + idx1, isNew1 := tg.TrackMessage("修改 model.yaml 中的 max_tokens 配置", 0) + if !isNew1 { + t.Fatal("first message should create new topic") + } + + idx2, isNew2 := tg.TrackMessage("把 context_window 也改大一些", 1) + if isNew2 { + t.Fatal("similar topic should not create new topic") + } + if idx1 != idx2 { + t.Fatalf("expected same topic, got %d and %d", idx1, idx2) + } +} + +func TestTrackMessageDifferentTopic(t *testing.T) { + tg := newTestGraph() + + idx1, _ := tg.TrackMessage("修改 model.yaml 中的 max_tokens 配置", 0) + idx2, isNew2 := tg.TrackMessage("关于鼠标右键粘贴的问题,如何适配", 1) + + if !isNew2 { + t.Fatal("different topic should create new topic") + } + if idx1 == idx2 { + t.Fatal("expected different topics") + } + + if len(tg.Topics) != 2 { + t.Fatalf("expected 2 topics, got %d", len(tg.Topics)) + } +} + +func TestTopicKeywords(t *testing.T) { + tg := newTestGraph() + + tg.TrackMessage("修改 deepseek 模型配置文件的 max_tokens", 0) + kw := tg.TopicKeywords(0) + if len(kw) == 0 { + t.Fatal("expected keywords") + } + // 关键词应该包含与主题相关的词 + t.Logf("keywords: %v", kw) +} + +func TestTrackFile(t *testing.T) { + tg := newTestGraph() + + idx, _ := tg.TrackMessage("修改 model.yaml 配置", 0) + tg.TrackFile(idx, "config/model.yaml") + tg.TrackFile(idx, "tui/model.go") + + if !tg.Topics[idx].Files["config/model.yaml"] { + t.Error("expected file to be tracked") + } + if !tg.Topics[idx].Files["tui/model.go"] { + t.Error("expected file to be tracked") + } +} + +func TestTopicOf(t *testing.T) { + tg := newTestGraph() + + tg.TrackMessage("msg 0", 0) + tg.TrackMessage("msg 1", 1) + tg.TrackMessage("msg 2", 2) + + if tg.TopicOf(0) != 0 { + t.Errorf("msg 0: expected topic 0, got %d", tg.TopicOf(0)) + } + if tg.TopicOf(-1) != -1 { + t.Error("expected -1 for out of bounds") + } + if tg.TopicOf(100) != -1 { + t.Error("expected -1 for out of bounds") + } +} + +func TestCurrentTopic(t *testing.T) { + tg := newTestGraph() + + if tg.CurrentTopic() != -1 { + t.Error("expected -1 for empty graph") + } + + tg.TrackMessage("first message", 0) + if tg.CurrentTopic() != 0 { + t.Errorf("expected topic 0, got %d", tg.CurrentTopic()) + } +} + +func TestRebuild(t *testing.T) { + history := []ChatMessage{ + {Role: "user", Content: "修改 model.yaml 中的 max_tokens"}, + {Role: "assistant", Content: "已修改 config/model.yaml"}, + {Role: "user", Content: "关于鼠标右键粘贴的问题"}, + {Role: "assistant", Content: "需要修改 tui/view.go 和 tui/model.go"}, + } + + tg := newTestGraph() + tg.Rebuild(history) + + if len(tg.Topics) != 2 { + t.Fatalf("expected 2 topics, got %d", len(tg.Topics)) + } + + // 第二个主题应该追踪到文件 + if len(tg.Topics[1].Files) == 0 { + t.Error("expected topic 1 to have tracked files") + } +} + +func TestCosineSimilarity(t *testing.T) { + a := map[string]float64{"hello": 1.0, "world": 0.5} + b := map[string]float64{"hello": 1.0, "world": 0.5} + + sim := cosineSimilarity(a, b) + if sim < 0.99 { + t.Errorf("identical vectors should have similarity ~1.0, got %f", sim) + } + + c := map[string]float64{"foo": 1.0, "bar": 0.5} + sim2 := cosineSimilarity(a, c) + if sim2 > 0.01 { + t.Errorf("disjoint vectors should have similarity ~0, got %f", sim2) + } +} + +func TestMergeVectors(t *testing.T) { + dst := map[string]float64{"a": 1.0} + src := map[string]float64{"b": 1.0} + + merged := mergeVectors(dst, src, 0.5) + // a: 1.0*0.5 = 0.5, b: 1.0*0.5 = 0.5 + if merged["a"] != 0.5 { + t.Errorf("expected a=0.5, got %f", merged["a"]) + } + if merged["b"] != 0.5 { + t.Errorf("expected b=0.5, got %f", merged["b"]) + } +} + +func TestExtractFileRefs(t *testing.T) { + content := "已修改 `tui/model.go` 和 `tui/view.go` 文件" + refs := extractFileRefs(content) + if len(refs) < 2 { + t.Fatalf("expected at least 2 file refs, got %d: %v", len(refs), refs) + } +} + +func TestExtractFileRefsHTTP(t *testing.T) { + content := "参考 https://example.com/file.go 文档" + refs := extractFileRefs(content) + if len(refs) != 0 { + t.Fatalf("HTTP URLs should not be treated as file refs, got %v", refs) + } +} + +func TestTokenizeCompoundCJK(t *testing.T) { + // 验证 CJK 词组不被拆成单字 + tokens := tokenize("分析I2 MAX代码中关于SACode相关信息") + t.Logf("tokens: %v", tokens) + + // SACode 应作为完整词出现 + foundSACode := false + for _, tok := range tokens { + if tok == "sacode" { + foundSACode = true + break + } + } + if !foundSACode { + t.Fatal("expected 'sacode' in tokens") + } + + // CJK 二元组应出现 + foundCompound := false + for _, tok := range tokens { + if len([]rune(tok)) >= 2 && isCJK([]rune(tok)[0]) { + foundCompound = true + break + } + } + if !foundCompound { + t.Fatal("expected at least one CJK bigram") + } +} + +func TestTopicKeywordsTiebreaker(t *testing.T) { + // 等分时, 长词优先 + tg := newTestGraph() + tg.TrackMessage("x y z abc defghijklmn", 0) + kw := tg.TopicKeywords(0) + t.Logf("keywords: %v", kw) + // 最长的词应该排第一个 + if len(kw) > 0 && kw[0] != "defghijklmn" { + t.Errorf("longest token should be first, got %q", kw[0]) + } +} + +// TestTopicGraphUsesSegmenter 验证 TopicGraph 使用注入的分词器而非默认 tokenize。 +type testSegmenter struct{} + +func (s *testSegmenter) Segment(text string) []string { + return []string{"custom", "segmenter"} +} +func (s *testSegmenter) Name() string { return "test" } + +func TestTopicGraphUsesSegmenter(t *testing.T) { + tg := NewTopicGraph(&testSegmenter{}, nil) + tokens := tg.Segment("任何文本") + if len(tokens) != 2 || tokens[0] != "custom" { + t.Fatalf("expected segmenter output, got %v", tokens) + } + idx, isNew := tg.TrackMessage("任何文本", 0) + if !isNew { + t.Fatal("expected new topic") + } + kw := tg.TopicKeywords(idx) + if len(kw) == 0 { + t.Fatal("expected keywords from segmenter") + } + t.Logf("keywords with test segmenter: %v", kw) +} + +// TestTopicGraphNilSegmenter 验证无分词器时 TopicGraph 不创建主题。 +func TestTopicGraphNilSegmenter(t *testing.T) { + tg := NewTopicGraph(nil, nil) // 无 segmenter + _, isNew := tg.TrackMessage("关注点识别", 0) + if isNew { + t.Fatal("expected no topic created without segmenter") + } + if len(tg.Topics) != 0 { + t.Fatalf("expected 0 topics, got %d", len(tg.Topics)) + } +} + +// TestStopWords 验证虚词过滤(POS 标签过滤, 由 dictSegmenter 内部处理)。 +func TestStopWords(t *testing.T) { + tg := newTestGraph() + idx, isNew := tg.TrackMessage("修改了 model.yaml 中的 max_tokens 配置", 0) + if !isNew { + t.Fatal("expected new topic") + } + kw := tg.TopicKeywords(idx) + t.Logf("keywords: %v", kw) + // 测试分词器的虚词已由 POS 标签过滤, 此处仅验证关键词不为空 + if len(kw) == 0 { + t.Fatal("expected keywords") + } +} \ No newline at end of file diff --git a/config/segmenter.go b/config/segmenter.go new file mode 100644 index 0000000..8749791 --- /dev/null +++ b/config/segmenter.go @@ -0,0 +1,76 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// SegmenterConfig 分词器独立配置, 存储于 ~/.deepx/segmenter.yaml。 +type SegmenterConfig struct { + // Language 分词语言。空/不配置 = 不启用。目前支持: "zh"(中文) + Language string `yaml:"language,omitempty"` + // DictURL 自定义词典下载地址。空则使用默认 jieba 词典。 + DictURL string `yaml:"dict_url,omitempty"` + // Embedder 嵌入器类型: "tfidf"(默认) / "onnx"(语义级)。 + // 设为 "onnx" 时启用 ONNX Sentence Embeddings, 首次使用自动下载模型。 + Embedder string `yaml:"embedder,omitempty"` +} + +const segmenterFileName = "segmenter.yaml" + +// SegmenterPath 返回 ~/.deepx/segmenter.yaml 绝对路径。 +func SegmenterPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("无法获取用户目录: %w", err) + } + return filepath.Join(home, dirName, segmenterFileName), nil +} + +// SegmenterDir 返回 ~/.deepx/segmenter/ 目录(词典缓存用)。 +func SegmenterDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("无法获取用户目录: %w", err) + } + return filepath.Join(home, dirName, "segmenter"), nil +} + +// LoadSegmenter 读 segmenter.yaml。文件不存在返回空配置(不启用), 不报错。 +func LoadSegmenter() (*SegmenterConfig, error) { + p, err := SegmenterPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + return &SegmenterConfig{}, nil + } + return nil, err + } + var c SegmenterConfig + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("解析 %s: %w", p, err) + } + return &c, nil +} + +// SaveSegmenter 写 segmenter.yaml。 +func SaveSegmenter(c *SegmenterConfig) error { + p, err := SegmenterPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { + return err + } + data, err := yaml.Marshal(c) + if err != nil { + return err + } + return os.WriteFile(p, data, 0600) +} diff --git a/tui/i18n.go b/tui/i18n.go index 9c38453..8162790 100644 --- a/tui/i18n.go +++ b/tui/i18n.go @@ -137,6 +137,10 @@ var translations = map[string]map[Lang]string{ LangZH: "撤销上一轮对话(原输入回填输入框)", LangEN: "Undo the last exchange (restores your input)", }, + "cmd.test.desc": { + LangZH: "开启/关闭测试模式(输出请求前处理信息)", + LangEN: "Toggle test mode (show pre-request processing info)", + }, "undo.done": { LangZH: "↩ 已撤销上一轮对话,原输入已回填输入框", LangEN: "↩ Undid the last exchange; your input is back in the box", diff --git a/tui/model.go b/tui/model.go index 5879573..fae72de 100644 --- a/tui/model.go +++ b/tui/model.go @@ -139,6 +139,9 @@ type model struct { // - setupStep = 0 选供应商 / 1 填配置(两步流程) // - setupCustomFields= 「其它」自定义的 10 个字段输入框(flash/pro 各 5);setupFieldIdx 为焦点 showSetup bool + showDriftConfirm bool // 发送前偏离确认弹窗 + pendingDriftInput string // 待确认的输入文本 + driftConfirmed bool // 确认后跳过重复检测 setupRequired bool setupInput textinput.Model setupErr string @@ -248,6 +251,7 @@ type model struct { // 0=flash.thinking, 1=flash.effort, 2=pro.thinking, 3=pro.effort。 // 每次 ←/→ 立刻写盘,所以无 draft / cancel 概念,Enter / Esc 都是关闭。 showReasoningModal bool + testMode bool // /test 测试模式: 输出请求前处理信息 reasoningModalRow int // inputDragging 表示左键在输入框区域按下后还没松开,用来实现"输入框拖拽选择片段": @@ -298,6 +302,12 @@ type model struct { // 不再作为 history[0] 消息存在;持久化在 state.json 的 summary 字段。 summary string + // topicGraph 是本地主题追踪图, 追踪对话主题的演化。 + // 每轮 user 消息后自动更新, 供压缩时策略性选择保留内容(Phase 2)。 + topicGraph *agent.TopicGraph + // lastFocusID 上一次焦点话题 ID, 用于 FocusChanged 检测。 + lastFocusID int + // 重启缓存友好压缩:detectRestartCompaction 检测到前缀变化时暂存上次前缀快照, // Init 时用 restartCompactionCmd 在首请求前跑一次压缩(见 prefix_cache.go)。 pendingCompactModel string @@ -697,6 +707,26 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub // 粘贴图片缓存:跟 OCR 解耦后改由这里按时效清理(超过 7 天的旧图删掉),不阻塞启动。 go tools.SweepPasteCache(7 * 24 * time.Hour) + // 加载分词器配置(~/.deepx/segmenter.yaml)。 + // 不配置 language 则不启用, topicGraph 保持 nil。 + segCfg, _ := config.LoadSegmenter() + var segmenter agent.Segmenter + var segErr error + if segCfg != nil && segCfg.Language != "" { + segCacheDir, _ := config.SegmenterDir() + segmenter, segErr = agent.NewSegmenter(segCfg.Language, segCacheDir) + } + // 创建嵌入器: 根据 embedder 配置(tfidf/onnx)选择, 默认 tfidf + var embedder agent.Embedder + if segCfg != nil && segCfg.Embedder != "" { + embCacheDir, _ := config.SegmenterDir() + embedder, _ = agent.NewEmbedder(agent.EmbedderType(segCfg.Embedder), embCacheDir) + } + var topicGraph *agent.TopicGraph + if segmenter != nil { + topicGraph = agent.NewTopicGraph(segmenter, embedder) + } + m := model{ mcpMgr: mcpMgr, mcpAddInput: mi, @@ -728,6 +758,7 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub hub: hub, srv: srv, webURL: webURL, + topicGraph: topicGraph, inputHistoryIndex: -1, } @@ -773,6 +804,10 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub gobHistory = gobHistory[1:] } m.history = gobHistory + // 从 gob 恢复的历史重建主题追踪图。 + if m.topicGraph != nil { + m.topicGraph.Rebuild(gobHistory) + } rebuildChatFromHistory(m.chatContent, gobHistory) // 老对话(升级前就有 history、没 conv.json)首次进 /sessions 别显示"(未命名)": // 用第一条用户消息回填标题。session 包自己解码不了 history.gob,放这儿做。 @@ -831,6 +866,11 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub } } + // 从 JSONL 兜底恢复的历史重建主题追踪图。 + if m.topicGraph != nil && len(m.history) > 0 { + m.topicGraph.Rebuild(m.history) + } + // 声明当前模式,通知 LLM 当前状态。模式始终从 auto 起步(默认全工具)。 // 注意:gob 恢复时跳过此步骤 — 历史已包含之前的 mode notification, // 重复追加会在每次重启时累积,污染 LLM 上下文。 @@ -855,6 +895,13 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub // 每次启动的欢迎语。 m.appendChat("System", T("welcome")) + // 分词器状态提示:配置了 segmenter: zh 但启动失败时告知用户。 + if segErr != nil { + m.appendChat("System", "⚠️ 分词器(zh)初始化失败: "+segErr.Error()) + } else if segCfg != nil && segCfg.Language != "" && segmenter == nil { + m.appendChat("System", "⚠️ 分词器("+segCfg.Language+")未就绪, 请检查网络后重启") + } + // web 控制面板启用时,在 chat 区给出可点击 / 可复制的地址 —— 浏览器里能新建会话、 // 切会话、切权限/沙箱/工作模式,状态与终端实时对齐。 if webURL != "" { @@ -1135,6 +1182,20 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { if input == "" && len(m.attachedImagePaths) == 0 { return m, nil } + // 发送前偏离检测: 会话已形成稳定关注点, 且当前输入与会话整体上下文语义差异过大时, + // 弹确认框让用户确认是否发送, 避免跑题内容浪费 token。 + if m.topicGraph != nil && m.topicGraph.FocusEstablished() && !m.showDriftConfirm && !m.driftConfirmed { + sim := m.topicGraph.SimilarityToSession(input) + if sim < agent.DriftDetectThreshold { + m.showDriftConfirm = true + m.pendingDriftInput = input + m.appendChat("System", fmt.Sprintf( + "⚠️ 当前输入与会话主题[%s]偏差较大(相似度 %.0f%%), 确认发送吗? 按 Enter 确认, Esc 取消", + m.topicBadge(), sim*100, + )) + return m, nil + } + } // 流式中 / 压缩前台期间再提交(主要是 web 端在生成时点发送)→ 排队而非丢弃,本轮(或压缩) // 结束后由 popQueuedInput 自动发出,与终端 Enter 完全一致:不开新 stream(杜绝并发两个 stream / // 与压缩截断 history 的竞态)、不丢字。终端 Enter 已在键处理处排队;这里兜 web 等其它入口 @@ -1166,6 +1227,18 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { userMsg := m.buildUserMessage(input) m.appendChat("You", input) m.history = append(m.history, userMsg) + // 本地主题追踪: 每轮 user 消息后更新主题图(仅分词器启用时)。 + if m.topicGraph != nil { + m.topicGraph.TrackMessage(input, len(m.history)-1) + // 话题切换检测: 有意义的旧话题存在, 且当前消息创建了新话题 → 提醒 + if switched, oldKW, _ := m.topicGraph.TopicSwitched(3); switched { + oldLabel := strings.Join(oldKW, " ") + m.appendChat("System", fmt.Sprintf( + "💡 当前话题与之前[%s]不同, 如需新建会话可输入 /new", + oldLabel, + )) + } + } // 对话还没标题时,用首条用户输入当标题(给 /sessions 列表显示)。 // 设了新标题就立刻把会话列表推给 web,否则浏览器一直显示"未命名",要切回来才更新。 if m.maybeSetConvTitle(input) { @@ -1190,15 +1263,88 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { m.refreshViewport() + // 测试模式: 输出发送给 AI 前的处理信息 + if m.testMode { + m.appendChat("System", fmt.Sprintf( + "🧪 **测试模式 - 请求前分析**\n\n"+ + "**模型路由**: %s → %s\n"+ + "**工作模式**: %s\n"+ + "**会话摘要**: %s\n"+ + "**历史消息数**: %d 轮\n"+ + "**主题追踪**: %s\n"+ + "**嵌入器**: %s\n"+ + "**发送前偏离检测**: 相似度 %.0f%%, 阈值 %.0f%%, %s", + m.activeModelRole, m.activeModelID, + m.workingMode, + truncTitle(m.summary, 60), + len(m.history), + func() string { + if m.topicGraph == nil { + return "未启用" + } + cur := m.topicGraph.CurrentTopic() + if cur < 0 { + return "无主题" + } + return m.topicGraph.SessionFocus() + }(), + func() string { + if m.topicGraph == nil { + return "无" + } + return m.topicGraph.EmbedderName() + }(), + func() float64 { + if m.topicGraph == nil { + return 100 + } + return m.topicGraph.SimilarityToSession(input) * 100 + }(), + agent.DriftDetectThreshold*100, + func() string { + if m.topicGraph == nil { + return "跳过" + } + sim := m.topicGraph.SimilarityToSession(input) + if sim < agent.DriftDetectThreshold { + return "⚠️ 偏离" + } + return "✅ 正常" + }(), + )) + } + var cmds []tea.Cmd cmds = append(cmds, m.spinner.Tick) - // 每次新用户消息开始,角色重置回 flash;agent 内部 keyword router 决定本轮真实模型。 - m.activeModelRole = "flash" - m.activeModelID = m.models.Flash.Model - if m.activeModelID == "" { - m.activeModelRole = "pro" - m.activeModelID = m.models.Pro.Model + // 上下文感知路由: 结合关键词 + 会话上下文决定起手模型。 + forceRole := "" + if m.modelPin == "" { + role := agent.RouteWithContext(input, m.topicGraph) + forceRole = role + m.activeModelRole = role + if role == "pro" { + m.activeModelID = m.models.Pro.Model + } else { + m.activeModelID = m.models.Flash.Model + } + if m.activeModelID == "" { + // 所选模型不可用, 回退另一个 + if m.models.Pro.Model != "" { + m.activeModelRole = "pro" + m.activeModelID = m.models.Pro.Model + } else { + m.activeModelRole = "flash" + m.activeModelID = m.models.Flash.Model + } + } + // 记录本轮路由结果到 TopicGraph(供下一轮上下文延续) + if m.topicGraph != nil { + m.topicGraph.LastModelRole = m.activeModelRole + } + } else { + m.activeModelRole = "flash" + m.activeModelID = m.models.Flash.Model } // 上一轮的 plan 清空 m.plan = nil @@ -1211,6 +1357,11 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { models := m.models models.Flash.Vision = m.visionByModel[modelCapKey(models.Flash)] models.Pro.Vision = m.visionByModel[modelCapKey(models.Pro)] + // 有效路由: 用户锁定优先, 否则使用上下文感知路由 + effectiveRole := forceRole + if m.modelPin != "" { + effectiveRole = m.modelPin + } cmd, ch := agent.StartStream( ctx, models, @@ -1219,7 +1370,7 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { workspace, m.skillCatalog, m.summary, - m.modelPin, + effectiveRole, m.workingMode, ) m.streamCh = ch @@ -1312,7 +1463,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case webCompactMsg: // 浏览器点"压缩会话":等价于 /compact。 - return m, m.startManualCompaction() + return m, m.startManualCompaction("") case webMcpAddMsg: // 浏览器添加 MCP server:落盘 + 后台连接,刷新工具集。 @@ -2364,6 +2515,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshViewport() return m, nil case "esc": + // 发送前偏离确认弹窗: Esc 取消发送 + if m.showDriftConfirm { + m.showDriftConfirm = false + m.pendingDriftInput = "" + m.appendChat("System", "已取消发送, 可修改后重新提交") + return m, nil + } // 正在拉 docker 镜像 → Esc 取消拉取,保持 native。 if m.dockerPulling { if m.dockerPullCancel != nil { @@ -2481,6 +2639,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } case "enter": + // 发送前偏离确认弹窗: Enter 确认发送 + if m.showDriftConfirm { + m.showDriftConfirm = false + m.driftConfirmed = true + input := m.pendingDriftInput + m.pendingDriftInput = "" + m, cmd := m.submitUserInput(input) + m.driftConfirmed = false + return m, cmd + } if m.streaming || m.compactingFG { // 流式中 / 压缩中:不打断,把这条排队,本轮(或压缩)结束后自动发送 // (见 queuedInput / StreamDoneMsg / compressionResultMsg)。压缩期间排队同样杜绝 @@ -2969,7 +3137,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.compacting = true m.compactingFG = true // 前台阻塞:同手动 /compact,footer 转 spinner + 期间挡输入。子 agent 走 runSubAgent 不经此处,天然例外。 // 前台阻塞 + spinner;排队输入推迟到 compressionResultMsg(压缩完成后)再发。 - return m, tea.Batch(m.compactCmd(false), m.spinner.Tick) + return m, tea.Batch(m.compactCmd(false, ""), m.spinner.Tick) } // 影子热压:上下文跨过 shadowPoints(30/45/60%)某档时,后台预算一份 checkpoint+cut 存盘 @@ -2993,7 +3161,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.shadowDonePct = next m.shadowing = true shadowCmd = func() tea.Msg { - cp, cut, _, err := agent.RunCompression(lastSys, lastTools, snapshot, entry, ctxWin) + cp, cut, _, err := agent.RunCompression(lastSys, lastTools, snapshot, entry, ctxWin, "") return shadowResultMsg{checkpoint: cp, cut: cut, gen: gen, err: err} } } @@ -3469,6 +3637,10 @@ func (m *model) handleSlashCommand(input string) tea.Cmd { if strings.HasPrefix(cmd, "/workflow") { // /workflows(列表) 或 /workflow <名字> [k=v…](保留原文大小写) return m.handleWorkflowCommand(input) } + if strings.HasPrefix(cmd, "/compact ") { // /compact <侧重点> → 按侧重点压缩 + focus := strings.TrimSpace(strings.TrimPrefix(cmd, "/compact ")) + return m.startManualCompaction(focus) + } if strings.HasPrefix(cmd, "/provider") { // 裸 /provider 弹选择器,或 /provider <名字> 直切 return m.handleProviderCommand(cmd) } @@ -3499,6 +3671,13 @@ func (m *model) handleSlashCommand(input string) tea.Cmd { m.openWebConfigModal() case "/reasoning": m.openReasoningModal() + case "/test": + m.testMode = !m.testMode + if m.testMode { + m.appendChat("System", "🧪 测试模式已开启, 下次请求前将输出处理信息") + } else { + m.appendChat("System", "测试模式已关闭") + } case "/lang": m.showLangModal = true // 默认光标停在当前语言上 @@ -3507,7 +3686,7 @@ func (m *model) handleSlashCommand(input string) tea.Cmd { m.langModalIdx = 1 } case "/compact": - return m.startManualCompaction() + return m.startManualCompaction("") case "/status": m.toggleStatusPanel() case "/thinking": @@ -3900,7 +4079,7 @@ func lockedModelMsg(role string) string { // compactCmd 构造一次"压缩 history → checkpoint"的后台 Cmd —— 手动 /compact 与自动 80% 触发共用。 // 拍 history 快照、复刻上次实际发送的 model/system/tools(命中热缓存);manual 供结果处理区分 // (失败时是否提示用户)。调用方负责置 m.compacting/m.compactingFG 与启动 spinner。 -func (m model) compactCmd(manual bool) tea.Cmd { +func (m model) compactCmd(manual bool, focusHint string) tea.Cmd { ctxWin := m.models.Pro.ContextWindow if ctxWin <= 0 { ctxWin = 65536 @@ -3909,7 +4088,7 @@ func (m model) compactCmd(manual bool) tea.Cmd { _, lastModel, lastSys, lastTools := m.session.LoadPrefixSnapshot() entry := m.entryForModel(lastModel) return func() tea.Msg { - summary, cutIdx, turns, err := agent.RunCompression(lastSys, lastTools, snapshot, entry, ctxWin) + summary, cutIdx, turns, err := agent.RunCompression(lastSys, lastTools, snapshot, entry, ctxWin, focusHint) // 手动 /compact 压不动(轮数不足/历史太短)→ 退而回收工具输出,别让用户白按一次。 // reclaim 就地改 snapshot(不改条数),有回收就把结果带回,由 compressionResultMsg 应用。 // 只在 manual 时兜底:后台自动触发的压缩失败,轮内 reclaim 自会在流式循环里处理。 @@ -4010,11 +4189,9 @@ func compactDoneNote(auto bool, turns int) string { return head + "(摘要已更新)" } -// startManualCompaction 处理 /compact:手动触发会话压缩,按 agent.CompactKeepTokens 保留尾部。 -// 与自动 80% 触发(StreamDoneMsg 里)走同一套 compactCmd + compressionResultMsg 流程, -// 区别只在于不看 token 阈值——用户敲了就压。压不动(历史太小)由 RunCompression 返回 err, -// 经 manual 标记在结果处理处反馈给用户。 -func (m *model) startManualCompaction() tea.Cmd { +// startManualCompaction 处理 /compact [focus]:手动触发会话压缩。 +// 可选参数 focus 指定压缩侧重点, 如 "/compact 重点保留缓存优化相关内容"。 +func (m *model) startManualCompaction(focus string) tea.Cmd { if m.session == nil || m.models.Pro.Model == "" { m.appendChat("System", "无可用会话或 Pro 模型,无法压缩") return nil @@ -4024,10 +4201,13 @@ func (m *model) startManualCompaction() tea.Cmd { return nil } m.compacting = true - m.compactingFG = true // 前台阻塞:footer 转 spinner、期间挡输入(compressionResultMsg 里清) - m.appendChat("System", "正在压缩会话历史…") - // 启动 spinner tick,让 footer 的「压缩中…」动起来(TickMsg 处理处会在 compactingFG 时续 tick)。 - return tea.Batch(m.compactCmd(true), m.spinner.Tick) + m.compactingFG = true + if focus != "" { + m.appendChat("System", fmt.Sprintf("正在按侧重点压缩会话历史: %s", focus)) + } else { + m.appendChat("System", "正在压缩会话历史…") + } + return tea.Batch(m.compactCmd(true, focus), m.spinner.Tick) } // === Skill 辅助 === diff --git a/tui/palette.go b/tui/palette.go index 89df1e2..18fb70d 100644 --- a/tui/palette.go +++ b/tui/palette.go @@ -44,6 +44,7 @@ func slashCommands() []struct{ name, desc string } { {"/sandbox", T("cmd.sandbox.desc")}, {"/working-mode", T("cmd.workingmode.desc")}, {"/undo", T("cmd.undo.desc")}, + {"/test", T("cmd.test.desc")}, {"/help", T("cmd.help.desc")}, {"/exit", T("cmd.exit.desc")}, } diff --git a/tui/prefix_cache.go b/tui/prefix_cache.go index 4937ad6..ccc8664 100644 --- a/tui/prefix_cache.go +++ b/tui/prefix_cache.go @@ -143,7 +143,7 @@ func (m *model) restartCompactionCmd() tea.Cmd { ctxWin = 65536 } return func() tea.Msg { - summary, cutIdx, compressedTurns, err := agent.RunCompression(oldSys, oldTools, snapshot, entry, ctxWin) + summary, cutIdx, compressedTurns, err := agent.RunCompression(oldSys, oldTools, snapshot, entry, ctxWin, "") return compressionResultMsg{ summary: summary, cutIdx: cutIdx, diff --git a/tui/session_modal.go b/tui/session_modal.go index 60ee8bd..99760aa 100644 --- a/tui/session_modal.go +++ b/tui/session_modal.go @@ -108,6 +108,17 @@ func (m *model) loadCurrentConversation() { } m.history = gobHistory rebuildChatFromHistory(m.chatContent, gobHistory) + // 切会话: 重建主题追踪图, 避免旧会话的 TF-IDF 文档频率污染新会话。 + if m.topicGraph != nil { + m.topicGraph.Rebuild(gobHistory) + m.lastFocusID = -1 + } + } else { + // 新会话无历史: 重置主题追踪图。 + if m.topicGraph != nil { + m.topicGraph.Rebuild(nil) + m.lastFocusID = -1 + } } } m.refreshViewport() diff --git a/tui/view.go b/tui/view.go index c5fa282..d9e584e 100644 --- a/tui/view.go +++ b/tui/view.go @@ -530,6 +530,9 @@ func (m model) statusFooterLine(_ int) string { if m.turnToolCalls > 0 { s += dim(" · " + strconv.Itoa(m.turnToolCalls) + " " + T("done.tools")) } + if badge := m.topicBadge(); badge != "" { + s += dim(" · " + badge) + } if m.mousePassthrough { s += dim(" · " + T("mouse.passthrough.badge")) } @@ -540,6 +543,9 @@ func (m model) statusFooterLine(_ int) string { if m.mousePassthrough { return dim(T("mouse.passthrough.badge")) } + if badge := m.topicBadge(); badge != "" { + return dim(badge) + } return "" } head := statusIcon(m.status) @@ -558,10 +564,28 @@ func (m model) statusFooterLine(_ int) string { if m.mousePassthrough { left += dim(" · " + T("mouse.passthrough.badge")) } + if badge := m.topicBadge(); badge != "" { + left += dim(" · " + badge) + } // 不再右贴 "Esc 中断" —— 输入框 placeholder(misc.input_placeholder)已含,避免重复。 return left } +// topicBadge 返回当前主题标签, 无主题时返回空。 +func (m model) topicBadge() string { + if m.session == nil { + return "" + } + title := m.session.ConvTitle() + if title == "" { + return "" + } + if len([]rune(title)) > 20 { + title = string([]rune(title)[:20]) + "…" + } + return title +} + func statusIcon(s string) string { switch s { case "idle": @@ -854,6 +878,14 @@ func (m model) rightPanelView() string { workspaceTitle += " " + subtle("("+m.session.SessionID()[:8]+")") } rows = append(rows, workspaceTitle, " "+subtle(cwd), "") + if m.session != nil { + if title := m.session.ConvTitle(); title != "" { + if len([]rune(title)) > 30 { + title = string([]rune(title)[:30]) + "…" + } + rows = append(rows, " "+subtle("📋 "+title)) + } + } // 模型厂商 section:api host(去 scheme / path),host 即可标识厂商。 endpoint := m.models.Flash.BaseURL