From c3c0cfd78b94a339f7186f4afca0700482920d1e Mon Sep 17 00:00:00 2001 From: yu-zhy Date: Wed, 5 Aug 2026 15:55:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=A8=A1=E5=9E=8B=E4=BC=98=E5=8C=96=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=A8=A1=E5=9E=8B=E8=B7=AF=E7=94=B1=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - segmenter.yaml 独立配置文件(不污染 model.yaml) - embedder: onnx 启动语义模型 默认使用bge-small-zh-v1.5 支持自动下载 - topic_tracking: true 启动主题追踪 - onnx_model: xxx 支持手动配置下载模型 add TopicGraph feature with ollama fix: PR #219 issue 全部修复 1. modelPin 判断: m.modelPin == "" || m.modelPin == "auto" (原恒为 false) 2. activeModelID 兜底: else 分支加回退逻辑 3. CI: go vet self-assignment 删除 + TF-IDF 首篇文档向量全零修复 4. 话题切换冲突: 移除 TopicSwitch 提示(保留 TopicGraph 用于路由) 5. ONNX 路径: ortLibName build tag 分平台 + ORT 引擎路径改为 ~/.deepx/ocr/ 6. ONNX mean pooling: 按 attention_mask 加权平均 + 归一化 7. 阈值分档: adjustedNewTopicThreshold() 按 embedder 类型返回不同值 8. token_type_ids: 补充缺失的 ONNX 模型输入(全零张量) --- agent/compact.go | 14 +- agent/compact_cooldown_test.go | 4 +- agent/compact_live_test.go | 2 +- agent/embedder.go | 36 +++ agent/embedder_onnx.go | 367 +++++++++++++++++++++++ agent/embedder_tfidf.go | 86 ++++++ agent/keyword_router.go | 119 +++++++- agent/llm.go | 2 +- agent/ort_darwin_amd64.go | 5 + agent/ort_darwin_arm64.go | 5 + agent/ort_engine.go | 67 +++++ agent/ort_linux_amd64.go | 5 + agent/ort_linux_arm64.go | 5 + agent/segmenter.go | 99 +++++++ agent/segmenter_dict.go | 376 +++++++++++++++++++++++ agent/topic_tracker.go | 527 +++++++++++++++++++++++++++++++++ agent/topic_tracker_test.go | 312 +++++++++++++++++++ config/segmenter.go | 81 +++++ tui/i18n.go | 4 + tui/model.go | 237 +++++++++++++-- tui/palette.go | 1 + tui/prefix_cache.go | 2 +- tui/session_modal.go | 11 + tui/view.go | 32 ++ 24 files changed, 2368 insertions(+), 31 deletions(-) create mode 100644 agent/embedder.go create mode 100644 agent/embedder_onnx.go create mode 100644 agent/embedder_tfidf.go create mode 100644 agent/ort_darwin_amd64.go create mode 100644 agent/ort_darwin_arm64.go create mode 100644 agent/ort_engine.go create mode 100644 agent/ort_linux_amd64.go create mode 100644 agent/ort_linux_arm64.go create mode 100644 agent/segmenter.go create mode 100644 agent/segmenter_dict.go create mode 100644 agent/topic_tracker.go create mode 100644 agent/topic_tracker_test.go create mode 100644 config/segmenter.go 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..4afdac8 --- /dev/null +++ b/agent/embedder.go @@ -0,0 +1,36 @@ +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, modelName string) (Embedder, error) { + switch t { + case EmbedderONNX: + if modelName == "" { + modelName = DefaultONNXModel + } + emb, err := newONNXEmbedder(cacheDir, modelName) + 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..45e7631 --- /dev/null +++ b/agent/embedder_onnx.go @@ -0,0 +1,367 @@ +package agent + +import ( + "fmt" + "io" + "math" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + ort "github.com/getcharzp/onnxruntime_purego" +) + +// onnxEmbedder 使用 ONNX Sentence Embeddings 模型生成语义向量。 +// 默认模型: bge-small-zh-v1.5 (384维, 轻量中文语义)。 +// 可通过 ~/.deepx/segmenter.yaml 中的 onnx_model_url / onnx_vocab_url 自定义。 +type onnxEmbedder struct { + mu sync.Mutex + session *ort.Session + vocab map[string]int32 // WordPiece 词汇表 + ready bool + modelName string // 模型名称(用于 Name() 返回) +} + +// onnxModelInfo 预注册的 ONNX 模型信息。 +type onnxModelInfo struct { + ModelURL string // ONNX 模型下载地址 + VocabURL string // 词汇表下载地址 +} + +// onnxModelRegistry 预注册的 ONNX 模型。 +// 默认模型 "bge-small-zh-v1.5" 可自动下载, 其他模型需手动下载。 +var onnxModelRegistry = map[string]onnxModelInfo{ + "bge-small-zh-v1.5": { + ModelURL: "https://hf-mirror.com/onnx-community/bge-small-zh-v1.5-ONNX/resolve/main/onnx/model.onnx", + VocabURL: "https://hf-mirror.com/BAAI/bge-small-zh-v1.5/resolve/main/vocab.txt", + }, + "text2vec-base-chinese": { + ModelURL: "https://hf-mirror.com/shibing624/text2vec-base-chinese/resolve/main/onnx/model.onnx", + VocabURL: "https://hf-mirror.com/shibing624/text2vec-base-chinese/resolve/main/vocab.txt", + }, +} + +// DefaultONNXModel 默认 ONNX 模型名称。 +const DefaultONNXModel = "bge-small-zh-v1.5" + +const onnxModelFile = "embedder_model.onnx" +const onnxVocabFile = "embedder_vocab.txt" + +// newONNXEmbedder 创建 ONNX 嵌入器。 +// modelName 为模型名, 空时使用默认模型。 +// 默认模型未下载时自动下载, 其他模型未下载时返回错误(提示手动下载)。 +func newONNXEmbedder(cacheDir, modelName string) (*onnxEmbedder, error) { + if modelName == "" { + modelName = DefaultONNXModel + } + info, ok := onnxModelRegistry[modelName] + if !ok { + // 未注册的模型: 必须手动下载, 检查文件是否存在 + modelPath := filepath.Join(cacheDir, onnxModelFile) + vocabPath := filepath.Join(cacheDir, onnxVocabFile) + if _, err := os.Stat(modelPath); os.IsNotExist(err) { + return nil, fmt.Errorf("模型 %q 未注册, 且 ONNX 模型文件未找到: %s\n请手动下载模型和词汇表到此目录", modelName, cacheDir) + } + if _, err := os.Stat(vocabPath); os.IsNotExist(err) { + return nil, fmt.Errorf("模型 %q 未注册, 且词汇表文件未找到: %s\n请手动下载", modelName, vocabPath) + } + return initONNXEmbedder(cacheDir, modelPath, vocabPath, modelName) + } + + modelPath := filepath.Join(cacheDir, onnxModelFile) + vocabPath := filepath.Join(cacheDir, onnxVocabFile) + + // 默认模型: 自动下载 + if modelName == DefaultONNXModel { + // 检查模型文件 + if _, err := os.Stat(modelPath); os.IsNotExist(err) { + if err := os.MkdirAll(cacheDir, 0700); err != nil { + return nil, fmt.Errorf("创建缓存目录失败: %w", err) + } + if err := downloadFileHTTP(info.ModelURL, modelPath); err != nil { + return nil, fmt.Errorf("下载 ONNX 模型失败: %w", err) + } + } + // 检查词汇表 + if _, err := os.Stat(vocabPath); os.IsNotExist(err) { + if err := os.MkdirAll(cacheDir, 0700); err != nil { + return nil, fmt.Errorf("创建缓存目录失败: %w", err) + } + if err := downloadFileHTTP(info.VocabURL, vocabPath); err != nil { + return nil, fmt.Errorf("下载词汇表失败: %w", err) + } + } + return initONNXEmbedder(cacheDir, modelPath, vocabPath, modelName) + } + + // 非默认模型: 必须手动下载 + if _, err := os.Stat(modelPath); os.IsNotExist(err) { + return nil, fmt.Errorf("ONNX 模型文件未找到: %s\n请手动下载模型: %s", modelPath, info.ModelURL) + } + if _, err := os.Stat(vocabPath); os.IsNotExist(err) { + return nil, fmt.Errorf("词汇表文件未找到: %s\n请手动下载: %s", vocabPath, info.VocabURL) + } + return initONNXEmbedder(cacheDir, modelPath, vocabPath, modelName) +} + +// initONNXEmbedder 加载模型和词汇表, 创建 ONNX 推理会话。 +func initONNXEmbedder(cacheDir, modelPath, vocabPath, modelName string) (*onnxEmbedder, error) { + // 验证词汇表文件 + str, err := os.Stat(vocabPath) + if err != nil { + return nil, fmt.Errorf("词汇表文件不可读: %w", err) + } + if str.Size() < 100 { + return nil, fmt.Errorf("词汇表文件无效(%d 字节), 请手动下载: %s", str.Size(), vocabPath) + } + + // 加载词汇表 + e := &onnxEmbedder{modelName: modelName} + if err := e.loadVocab(vocabPath); err != nil { + return nil, fmt.Errorf("加载词汇表失败: %w", err) + } + + // 获取共享 ONNX Runtime 引擎(使用 OCR 的缓存目录, 复用已有的 ONNX Runtime 共享库) + ortDir := filepath.Join(filepath.Dir(cacheDir), "ocr") + engine, err := GetORTEngine(ortDir) + 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() + + // token_type_ids: 全零(单段输入, 不需要区分 segment) + tokenTypes := make([]int64, len(inputIDs)) + typeTensor, err := ort.NewTensor([]int64{1, int64(len(tokenTypes))}, tokenTypes) + if err != nil { + return nil + } + defer typeTensor.Destroy() + + // ONNX 推理 + outputs, err := e.session.Run(map[string]*ort.Value{ + "input_ids": inputTensor, + "attention_mask": maskTensor, + "token_type_ids": typeTensor, + }) + if err != nil || len(outputs) == 0 { + return nil + } + // 获取输出(last_hidden_state, shape [1, seq_len, 384]) + var outVal *ort.Value + for _, v := range outputs { + outVal = v + break + } + defer outVal.Destroy() + + raw, err := ort.GetTensorData[float32](outVal) + if err != nil { + return nil + } + + // mean pooling: 按 attention_mask 对 token 向量加权平均得到句向量 + seqLen := len(attentionMask) + dim := len(raw) / seqLen + vec := make(map[string]float64, dim) + maskSum := 0.0 + for t := 0; t < seqLen; t++ { + if attentionMask[t] == 0 { + continue + } + maskSum++ + for d := 0; d < dim; d++ { + vec[fmt.Sprintf("d%d", d)] += float64(raw[t*dim+d]) + } + } + if maskSum > 0 { + for k := range vec { + vec[k] /= maskSum + } + } + // 归一化 + var norm float64 + for _, v := range vec { + norm += v * v + } + norm = math.Sqrt(norm) + if norm > 0 { + for k := range vec { + vec[k] /= norm + } + } + return vec +} + +func (e *onnxEmbedder) Name() string { + if e.modelName != "" { + return "onnx(" + e.modelName + ")" + } + return "onnx" +} + +// 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..51baf44 --- /dev/null +++ b/agent/embedder_tfidf.go @@ -0,0 +1,86 @@ +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 + } + vec := e.extractTFIDF(tokens) + e.updateDocFreq(tokens) + return vec +} + +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..60176cf 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,117 @@ var complexKeywords = []string{ "조사", "근본 원인", } + +// === 语义路由 === + +// complexTaskPatterns 是描述"需要 pro 模型推理"的语义原子。 +// 每个原子是对一类复杂任务的完整语义描述,不是关键词——语义模型通过 +// 余弦相似度匹配的是"整体语义",而非词表命中。 +// 设计原则: 宽泛到能覆盖同类变体,精确到能区分简单任务。 +var complexTaskPatterns = []string{ + // 重构: 跨文件代码修改,需要理解架构和依赖 + "大规模代码重构需要理解整个系统的架构和模块间的依赖关系", + // 分析: 深度分析系统设计,需要推理因果关系 + "分析复杂系统设计文档架构决策调用链和依赖图并推理因果", + // 调试: 排查性能瓶颈、内存泄漏、并发问题等根因分析 + "排查性能瓶颈内存泄漏死锁并发问题并分析根因修复方案", + // 设计: 设计新的模块接口、API 规范、抽象层和架构 + "设计新的模块接口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 上下文感知路由,决定使用 flash 还是 pro 模型。 +// 当 ONNX 嵌入器可用时完全使用语义匹配,否则使用关键词回退。 +// 参数: +// userMsg - 用户当前输入 +// tg - 主题追踪图(nil 时退化为纯关键词路由) +// 返回 "flash" 或 "pro"。 +func RouteWithContext(userMsg string, tg *TopicGraph) string { + // ONNX 模式下: 语义匹配优先(完全替代关键词) + if tg != nil && tg.embedder != nil && strings.HasPrefix(tg.embedder.Name(), "onnx") { + // 1. 语义匹配: 与复杂任务模式相似度 + if r := RouteBySemantic(userMsg, tg.embedder); r == "pro" { + return r + } + // 2. 上下文感知: 短消息延续上轮模型 + if tg.FocusEstablished() { + sim := tg.SimilarityToSession(userMsg) + if len([]rune(userMsg)) < 100 && sim > 0.3 && tg.LastModelRole != "" { + return tg.LastModelRole + } + if sim < 0.02 { + return "flash" + } + } + // 3. 长度启发: 超长消息需要 pro + if len([]rune(userMsg)) > 500 { + return "pro" + } + return "flash" + } + + // TF-IDF 模式: 关键词 + 上下文 + role := RouteByKeyword(userMsg) + if role == "pro" { + return role + } + if tg != nil && tg.FocusEstablished() { + sim := tg.SimilarityToSession(userMsg) + if len([]rune(userMsg)) < 100 && sim > 0.3 && tg.LastModelRole != "" { + return tg.LastModelRole + } + if sim < 0.02 { + return "flash" + } + } + return role +} diff --git a/agent/llm.go b/agent/llm.go index a3a9362..d55646c 100644 --- a/agent/llm.go +++ b/agent/llm.go @@ -733,7 +733,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_darwin_amd64.go b/agent/ort_darwin_amd64.go new file mode 100644 index 0000000..671899d --- /dev/null +++ b/agent/ort_darwin_amd64.go @@ -0,0 +1,5 @@ +//go:build darwin && amd64 + +package agent + +const ortLibName = "onnxruntime_amd64.dylib" diff --git a/agent/ort_darwin_arm64.go b/agent/ort_darwin_arm64.go new file mode 100644 index 0000000..ae50d4a --- /dev/null +++ b/agent/ort_darwin_arm64.go @@ -0,0 +1,5 @@ +//go:build darwin && arm64 + +package agent + +const ortLibName = "onnxruntime_arm64.dylib" diff --git a/agent/ort_engine.go b/agent/ort_engine.go new file mode 100644 index 0000000..08c030c --- /dev/null +++ b/agent/ort_engine.go @@ -0,0 +1,67 @@ +package agent + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sync" + + ort "github.com/getcharzp/onnxruntime_purego" +) + +// ortEngine 共享的 ONNX Runtime 引擎(单例)。 +var ( + ortEngineOnce sync.Once + ortEngineInst *ortEngine + ortEngineErr error +) + +type ortEngine struct { + engine *ort.Engine +} + +// 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}, 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 +} diff --git a/agent/ort_linux_amd64.go b/agent/ort_linux_amd64.go new file mode 100644 index 0000000..3b08632 --- /dev/null +++ b/agent/ort_linux_amd64.go @@ -0,0 +1,5 @@ +//go:build linux && amd64 + +package agent + +const ortLibName = "onnxruntime_amd64.so" diff --git a/agent/ort_linux_arm64.go b/agent/ort_linux_arm64.go new file mode 100644 index 0000000..fb28e31 --- /dev/null +++ b/agent/ort_linux_arm64.go @@ -0,0 +1,5 @@ +//go:build linux && arm64 + +package agent + +const ortLibName = "onnxruntime_arm64.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..f61d243 --- /dev/null +++ b/agent/topic_tracker.go @@ -0,0 +1,527 @@ +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 + } + 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 适用于 TF-IDF 稀疏向量; ONNX 稠密向量需使用 adjustedNewTopicThreshold。 +const newTopicThreshold = 0.15 + +// adjustedNewTopicThreshold 返回适配当前嵌入器的新主题阈值。 +// TF-IDF: 0.15(稀疏向量需要较低阈值区分) +// ONNX: 0.4(稠密向量相似度普遍偏高) +func (tg *TopicGraph) adjustedNewTopicThreshold() float64 { + if tg.embedder != nil && strings.HasPrefix(tg.embedder.Name(), "onnx") { + return 0.4 + } + return newTopicThreshold +} + +// 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 < tg.adjustedNewTopicThreshold() { + // 创建新主题 + 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] + // ONNX 语义向量: 无关键词可提取, 返回空 + if tg.embedder != nil && strings.HasPrefix(tg.embedder.Name(), "onnx") { + return "" + } + 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..8dedaeb --- /dev/null +++ b/config/segmenter.go @@ -0,0 +1,81 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// SegmenterConfig 分词器独立配置, 存储于 ~/.deepx/segmenter.yaml。 +// SegmenterConfig 分词器独立配置, 存储于 ~/.deepx/segmenter.yaml。 +type SegmenterConfig struct { + // TopicTracking 是否启用主题追踪(开启后启用新路由 + 偏离检测 + 语义匹配)。 + // 设为 true 时自动创建分词器和嵌入器。默认 false。 + TopicTracking bool `yaml:"topic_tracking,omitempty"` + // DictURL 自定义词典下载地址。空则使用默认 jieba 词典。 + DictURL string `yaml:"dict_url,omitempty"` + // Embedder 嵌入器类型: "tfidf"(默认) / "onnx"(语义级)。 + // 设为 "onnx" 时启用 ONNX Sentence Embeddings, 首次使用自动下载模型。 + Embedder string `yaml:"embedder,omitempty"` + // ONNXModel ONNX 语义模型名。默认 "bge-small-zh-v1.5"(自动下载)。 + // 其他模型需手动下载, 未下载时回退 TF-IDF。 + ONNXModel string `yaml:"onnx_model,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 9ef2769..266a4be 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 a01d4cc..e6df8ab 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 @@ -253,6 +256,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 表示左键在输入框区域按下后还没松开,用来实现"输入框拖拽选择片段": @@ -303,6 +307,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 @@ -702,6 +712,27 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub // 粘贴图片缓存:跟 OCR 解耦后改由这里按时效清理(超过 7 天的旧图删掉),不阻塞启动。 go tools.SweepPasteCache(7 * 24 * time.Hour) + // 加载分词器配置(~/.deepx/segmenter.yaml)。 + // topic_tracking: true 时创建 TopicGraph 启用新路由。 + segCfg, _ := config.LoadSegmenter() + var segmenter agent.Segmenter + var segErr error + var embedder agent.Embedder + if segCfg != nil && segCfg.TopicTracking { + // 创建分词器(中文词典, 用于 TF-IDF 回退) + segCacheDir, _ := config.SegmenterDir() + segmenter, segErr = agent.NewSegmenter("zh", segCacheDir) + // 创建嵌入器 + if segCfg.Embedder != "" { + embCacheDir, _ := config.SegmenterDir() + embedder, _ = agent.NewEmbedder(agent.EmbedderType(segCfg.Embedder), embCacheDir, segCfg.ONNXModel) + } + } + var topicGraph *agent.TopicGraph + if segmenter != nil { + topicGraph = agent.NewTopicGraph(segmenter, embedder) + } + m := model{ mcpMgr: mcpMgr, mcpAddInput: mi, @@ -733,6 +764,7 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub hub: hub, srv: srv, webURL: webURL, + topicGraph: topicGraph, inputHistoryIndex: -1, } @@ -783,6 +815,10 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub } m.history = gobHistory m.topic = lastTopicOf(gobHistory) // 冷启动恢复右栏主题(不额外存盘,从历史里读回) + // 从 gob 恢复的历史重建主题追踪图(用于路由, 独立于 LLM 主题检测)。 + if m.topicGraph != nil { + m.topicGraph.Rebuild(gobHistory) + } rebuildChatFromHistory(m.chatContent, gobHistory) // 老对话(升级前就有 history、没 conv.json)首次进 /sessions 别显示"(未命名)": // 用第一条用户消息回填标题。session 包自己解码不了 history.gob,放这儿做。 @@ -841,6 +877,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 上下文。 @@ -865,6 +906,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.TopicTracking && segmenter == nil { + m.appendChat("System", "⚠️ 分词器未就绪, 请检查网络后重启") + } + // web 控制面板启用时,在 chat 区给出可点击 / 可复制的地址 —— 浏览器里能新建会话、 // 切会话、切权限/沙箱/工作模式,状态与终端实时对齐。 if webURL != "" { @@ -1145,6 +1193,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 等其它入口 @@ -1176,6 +1238,10 @@ 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) + } // 对话还没标题时,用首条用户输入当标题(给 /sessions 列表显示)。 // 设了新标题就立刻把会话列表推给 web,否则浏览器一直显示"未命名",要切回来才更新。 if m.maybeSetConvTitle(input) { @@ -1201,15 +1267,108 @@ 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 "无主题" + } + // ONNX 模式下: 显示会话名称 + if strings.HasPrefix(m.topicGraph.EmbedderName(), "onnx") { + if title := m.session.ConvTitle(); title != "" { + return truncTitle(title, 30) + } + 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 == "" || m.modelPin == "auto" { + 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 = m.modelPin + if m.modelPin == "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 + } + } } // 上一轮的 plan 清空 m.plan = nil @@ -1222,6 +1381,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, @@ -1230,7 +1394,7 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { workspace, m.skillCatalog, m.summary, - m.modelPin, + effectiveRole, m.workingMode, ) m.streamCh = ch @@ -1323,7 +1487,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:落盘 + 后台连接,刷新工具集。 @@ -2375,6 +2539,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 { @@ -2492,6 +2663,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)。压缩期间排队同样杜绝 @@ -3003,7 +3184,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 存盘 @@ -3027,7 +3208,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} } } @@ -3506,6 +3687,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) } @@ -3536,6 +3721,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 // 默认光标停在当前语言上 @@ -3544,7 +3736,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": @@ -3948,7 +4140,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 @@ -3957,7 +4149,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 自会在流式循环里处理。 @@ -4058,11 +4250,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 @@ -4072,10 +4262,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 f8b568a..7d6a7b9 100644 --- a/tui/session_modal.go +++ b/tui/session_modal.go @@ -112,6 +112,17 @@ func (m *model) loadCurrentConversation() { m.history = gobHistory m.topic = lastTopicOf(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 502f783..c198001 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 From a37aa0b2c5351998eb914c8b82a420ea3d1208f8 Mon Sep 17 00:00:00 2001 From: yu-zhy Date: Fri, 7 Aug 2026 22:34:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=E6=A8=A1=E5=9E=8B=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E7=AD=96=E7=95=A5=E8=BF=BD=E5=8A=A0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=8F=AF=E9=85=8D=E7=BD=AE=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1、建议重构模型路由策略实现,支持用户自定义模型路由语义元句 2、支持模型路由回降机制,进一步降低成本 3、优化启动速度,可异步加载语义模型 Fixs #236 --- agent/embedder_tfidf.go | 8 +- agent/keyword_router.go | 115 +-------------------- agent/llm.go | 39 ++++++- agent/router/context.go | 23 +++++ agent/router/decision.go | 10 ++ agent/router/learner.go | 107 ++++++++++++++++++++ agent/router/matcher.go | 117 +++++++++++++++++++++ agent/router/patterns.go | 83 +++++++++++++++ agent/router/patterns/complex.toml | 15 +++ agent/router/patterns/deep.toml | 14 +++ agent/router/patterns/medium.toml | 13 +++ agent/router/patterns/simple.toml | 13 +++ agent/router/router.go | 79 +++++++++++++++ agent/topic_tracker.go | 27 ++++- agent/topic_tracker_test.go | 6 +- tui/exec.go | 2 +- tui/model.go | 157 +++++++++++++++++++++++++++-- tui/view.go | 4 + 18 files changed, 695 insertions(+), 137 deletions(-) create mode 100644 agent/router/context.go create mode 100644 agent/router/decision.go create mode 100644 agent/router/learner.go create mode 100644 agent/router/matcher.go create mode 100644 agent/router/patterns.go create mode 100644 agent/router/patterns/complex.toml create mode 100644 agent/router/patterns/deep.toml create mode 100644 agent/router/patterns/medium.toml create mode 100644 agent/router/patterns/simple.toml create mode 100644 agent/router/router.go diff --git a/agent/embedder_tfidf.go b/agent/embedder_tfidf.go index 51baf44..13b1cf3 100644 --- a/agent/embedder_tfidf.go +++ b/agent/embedder_tfidf.go @@ -41,7 +41,8 @@ func (e *tfidfEmbedder) extractTFIDF(tokens []string) map[string]float64 { 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)) + // +2 平滑: 避免首篇文档中出现在所有文档的词的 IDF 为 0 + idf := math.Log(float64(e.totalDocs+2) / float64(df)) vec[term] = tfVal * idf } return vec @@ -84,3 +85,8 @@ func (e *tfidfEmbedder) TopKeywords(vec map[string]float64, n int) []string { } return out } + +// NewTFIDFEmbedder 创建 TF-IDF 嵌入器(公开构造器)。 +func NewTFIDFEmbedder() *tfidfEmbedder { + return newTFIDFEmbedder() +} diff --git a/agent/keyword_router.go b/agent/keyword_router.go index 60176cf..13dd670 100644 --- a/agent/keyword_router.go +++ b/agent/keyword_router.go @@ -2,7 +2,7 @@ package agent import ( "strings" - "sync" + ) // RouteByKeyword 是入口路由的确定性版本 — 纯本地、零延迟,替代之前的 LLM classifier。 @@ -192,116 +192,3 @@ var complexKeywords = []string{ "근본 원인", } -// === 语义路由 === - -// complexTaskPatterns 是描述"需要 pro 模型推理"的语义原子。 -// 每个原子是对一类复杂任务的完整语义描述,不是关键词——语义模型通过 -// 余弦相似度匹配的是"整体语义",而非词表命中。 -// 设计原则: 宽泛到能覆盖同类变体,精确到能区分简单任务。 -var complexTaskPatterns = []string{ - // 重构: 跨文件代码修改,需要理解架构和依赖 - "大规模代码重构需要理解整个系统的架构和模块间的依赖关系", - // 分析: 深度分析系统设计,需要推理因果关系 - "分析复杂系统设计文档架构决策调用链和依赖图并推理因果", - // 调试: 排查性能瓶颈、内存泄漏、并发问题等根因分析 - "排查性能瓶颈内存泄漏死锁并发问题并分析根因修复方案", - // 设计: 设计新的模块接口、API 规范、抽象层和架构 - "设计新的模块接口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 上下文感知路由,决定使用 flash 还是 pro 模型。 -// 当 ONNX 嵌入器可用时完全使用语义匹配,否则使用关键词回退。 -// 参数: -// userMsg - 用户当前输入 -// tg - 主题追踪图(nil 时退化为纯关键词路由) -// 返回 "flash" 或 "pro"。 -func RouteWithContext(userMsg string, tg *TopicGraph) string { - // ONNX 模式下: 语义匹配优先(完全替代关键词) - if tg != nil && tg.embedder != nil && strings.HasPrefix(tg.embedder.Name(), "onnx") { - // 1. 语义匹配: 与复杂任务模式相似度 - if r := RouteBySemantic(userMsg, tg.embedder); r == "pro" { - return r - } - // 2. 上下文感知: 短消息延续上轮模型 - if tg.FocusEstablished() { - sim := tg.SimilarityToSession(userMsg) - if len([]rune(userMsg)) < 100 && sim > 0.3 && tg.LastModelRole != "" { - return tg.LastModelRole - } - if sim < 0.02 { - return "flash" - } - } - // 3. 长度启发: 超长消息需要 pro - if len([]rune(userMsg)) > 500 { - return "pro" - } - return "flash" - } - - // TF-IDF 模式: 关键词 + 上下文 - role := RouteByKeyword(userMsg) - if role == "pro" { - return role - } - if tg != nil && tg.FocusEstablished() { - sim := tg.SimilarityToSession(userMsg) - if len([]rune(userMsg)) < 100 && sim > 0.3 && tg.LastModelRole != "" { - return tg.LastModelRole - } - if sim < 0.02 { - return "flash" - } - } - return role -} diff --git a/agent/llm.go b/agent/llm.go index d55646c..5611e73 100644 --- a/agent/llm.go +++ b/agent/llm.go @@ -113,6 +113,8 @@ type ModelSwitchMsg struct { Role string // "flash" or "pro" ModelID string // 实际 model id Reason string // 可选,描述路由依据(目前为空,B 方案静默路由) + // CompressNow 建议立即压缩上下文(因上下文压力升级时设置)。 + CompressNow bool } // HistoryUpdateMsg 让 UI 用最新的 history 替换本地副本(包含 assistant tool_calls / tool 结果) @@ -602,6 +604,8 @@ func StartStream( summary string, // 会话压缩摘要,垫在 system prompt 末尾;空串表示尚未压缩 forceRole string, // 用户锁定的模型角色("flash"/"pro");空串或 "auto" 表示走关键词路由 workingMode WorkingMode, // 工作模式:每轮把对应 skill 引导追加到最后一条 user 消息(renderWorkingMode) + reasoningEffort string, // 路由决策的推理深度(""/"medium"/"high");空时使用模型默认配置 + thinking string, // 路由决策的 thinking 模式(""/"enabled"/"disabled");空时使用模型默认配置 ) (tea.Cmd, <-chan tea.Msg) { ch := make(chan tea.Msg, 128) @@ -694,6 +698,7 @@ func StartStream( lastPromptTokens := 0 inLoopCompactOff := false compactCooldown := 0 + forceCompaction := false // SwitchModel 因上下文压力升级时设为 true, 跳过阈值直接压缩 for { // 检查 context 是否取消(ESC/退出),提前退出不卡后台 @@ -729,8 +734,9 @@ func StartStream( // 对标 Claude Code:压缩 convo[1:] 成摘要、重建 [system(新摘要)]+尾部,新摘要经 CompactedMsg // 回传 TUI 存 session(否则被剥的 system 摘要会丢失),history 截断经 HistoryUpdateMsg 同步。 if ctxWin := currentEntry.ContextWindow; !inLoopCompactOff && compactCooldown == 0 && ctxWin > 0 && - lastPromptTokens >= CompactTriggerTokens(ctxWin) && + (lastPromptTokens >= CompactTriggerTokens(ctxWin) || forceCompaction) && len(convo) > 0 && convo[0].Role == "system" { + forceCompaction = false // 重置 hist := convo[1:] ch <- CompactingMsg{} // 先亮状态行:下面这行最长会卡 10 分钟,期间不吐任何 token sum, cutIdx, turns, cerr := RunCompression(convo[0].Content, MarshalToolSpecs(toolSpecs), hist, currentEntry, ctxWin, "") @@ -766,11 +772,24 @@ func StartStream( // 渲染后的副本才是真正发出的输入 —— max_tokens 夹取按它估算(渲染会追加 OCR 文本等, // 比规范 convo 大;按规范估会低估输入、夹不住,仍可能爆窗)。 rendered := renderConvoImages(renderWorkingMode(convo, workingMode), currentEntry.Vision) + // 路由决策的推理深度覆盖模型默认配置 + effort := currentEntry.ReasoningEffort + if reasoningEffort != "" { + effort = reasoningEffort + } + // 路由决策的 thinking 模式覆盖模型默认配置 + if thinking != "" { + currentEntry.Thinking = thinking + } + // 安全兜底: 使用 reasoning_effort 时强制关闭 thinking(两者冗余) + if effort != "" && currentEntry.Thinking != "disabled" { + currentEntry.Thinking = "disabled" + } assistantContent, reasoning, toolCalls, finishReason, usage, err := streamOnce( ctx, currentEntry.APIKey, currentEntry.BaseURL, currentEntry.Model, rendered, clampMaxTokens(currentEntry.MaxTokens, currentEntry.ContextWindow, rendered), toolSpecs, - currentEntry.ReasoningEffort, currentEntry.Thinking, + effort, currentEntry.Thinking, ch, ) // 自愈兜底:被端点以"不支持图片输入"拒掉(无论 base64 是探测误判发的、还是历史里混进来的)→ @@ -1047,7 +1066,10 @@ func StartStream( role = tools.RolePro currentEntry = models.Pro // 工具表不随角色变(各角色一致),无需重算 toolSpecs。 - ch <- ModelSwitchMsg{Role: role, ModelID: currentEntry.Model, Reason: reason} + ch <- ModelSwitchMsg{Role: role, ModelID: currentEntry.Model, Reason: reason, CompressNow: isContextPressure(reason)} + if isContextPressure(reason) { + forceCompaction = true + } result = tools.ToolResult{ Output: fmt.Sprintf("已切到 pro 模型 (%s)。本轮剩余请求 + reasoning 用 pro 处理。", currentEntry.Model), Success: true, @@ -1949,3 +1971,14 @@ func toolOutputReference(name, path string) string { } return reclaimMarkerPrefix + label + " 的旧输出已省略以回收上下文,需要时请重新调用获取。" } + +// isContextPressure 判断 SwitchModel 升级理由是否因上下文压力。 +func isContextPressure(reason string) bool { + // 工具描述中 #10 明确说明: "上下文接近窗口限制,需要更大窗口模型" + // 模型可能用不同措辞, 关键特征: 提及窗口/上下文/容量已满 + lower := strings.ToLower(reason) + return strings.Contains(lower, "窗口") || + strings.Contains(lower, "上下文") || + (strings.Contains(lower, "context") && strings.Contains(lower, "window")) || + strings.Contains(lower, "70%") +} diff --git a/agent/router/context.go b/agent/router/context.go new file mode 100644 index 0000000..dfd8733 --- /dev/null +++ b/agent/router/context.go @@ -0,0 +1,23 @@ +package router + +import ( + "deepx/agent" +) + +// ContextAnalyzer 基于会话上下文分析路由决策。 +// 当前仅用于跟踪上轮模型选择,不做路由干预。 +type ContextAnalyzer struct { + tg *agent.TopicGraph +} + +// NewContextAnalyzer 创建上下文分析器。 +func NewContextAnalyzer(tg *agent.TopicGraph) *ContextAnalyzer { + return &ContextAnalyzer{tg: tg} +} + +// RecordLastModel 记录本轮模型选择,供 TopicGraph 跟踪。 +func (c *ContextAnalyzer) RecordLastModel(role string) { + if c.tg != nil { + c.tg.LastModelRole = role + } +} diff --git a/agent/router/decision.go b/agent/router/decision.go new file mode 100644 index 0000000..9a94108 --- /dev/null +++ b/agent/router/decision.go @@ -0,0 +1,10 @@ +package router + +// RouteDecision 是路由决策结果,包含模型选择、推理深度和决策依据。 +type RouteDecision struct { + Role string // "flash" / "pro" + ReasoningEffort string // "" / "medium" / "high" (仅 pro 生效) + Thinking string // "" / "enabled" / "disabled" (仅 flash 生效) + Source string // 决策来源: "semantic_simple" / "semantic_medium" / "semantic_complex" / "context" / "length" / "fallback" + Trace string // 人类可读的决策依据 +} diff --git a/agent/router/learner.go b/agent/router/learner.go new file mode 100644 index 0000000..37e3c8d --- /dev/null +++ b/agent/router/learner.go @@ -0,0 +1,107 @@ +package router + +import ( + "fmt" + "math" + "sort" + "strings" + "sync" + + "deepx/agent" +) + +// BlindSpot 记录语义匹配遗漏的输入模式。 +type BlindSpot struct { + Centroid map[string]float64 // 遗漏输入的语义重心 + Count int // 累积次数 + Samples []string // 最多 3 条示例 + MaxSim float64 // 与最佳元句的相似度 + Target string // 应路由到的等级 (simple/medium/complex/deep) +} + +// BlindSpotDetector 语义盲区检测器。 +type BlindSpotDetector struct { + mu sync.Mutex + spots []*BlindSpot + embedder agent.Embedder +} + +// NewBlindSpotDetector 创建盲区检测器。 +func NewBlindSpotDetector(emb agent.Embedder) *BlindSpotDetector { + return &BlindSpotDetector{embedder: emb} +} + +// Record 记录一次语义匹配遗漏。 +// bestSim 是与最佳语义元句的相似度, target 是应路由到的等级。 +func (d *BlindSpotDetector) Record(input string, vec map[string]float64, bestSim float64, target string) { + d.mu.Lock() + defer d.mu.Unlock() + + if len(vec) == 0 { + return + } + + // 查找同类盲区(相似度 > 0.7) + for _, s := range d.spots { + if agent.CosineSimilarity(vec, s.Centroid) > 0.7 { + s.Count++ + if len(s.Samples) < 3 { + s.Samples = append(s.Samples, input) + } + s.MaxSim = math.Max(s.MaxSim, bestSim) + // 合并到重心 + for k, v := range s.Centroid { + s.Centroid[k] = v * 0.9 + } + for k, v := range vec { + s.Centroid[k] += v * 0.1 + } + return + } + } + + // 创建新盲区 + d.spots = append(d.spots, &BlindSpot{ + Centroid: vec, + Count: 1, + Samples: []string{input}, + MaxSim: bestSim, + Target: target, + }) +} + +// ReviewSpots 返回需要审查的盲区(≥ 5 次)。 +func (d *BlindSpotDetector) ReviewSpots() []*BlindSpot { + d.mu.Lock() + defer d.mu.Unlock() + var out []*BlindSpot + for _, s := range d.spots { + if s.Count >= 5 { + out = append(out, s) + } + } + sort.Slice(out, func(i, j int) bool { + return out[i].Count > out[j].Count + }) + return out +} + +// ReviewHint 返回审查提示文本。 +func (d *BlindSpotDetector) ReviewHint() string { + spots := d.ReviewSpots() + if len(spots) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\n⚠️ 语义匹配盲区:\n") + for _, s := range spots { + b.WriteString(fmt.Sprintf(" • %d 次漏判, 最佳相似度 %.0f%%, 应路由到 %s\n", s.Count, s.MaxSim*100, s.Target)) + for _, ex := range s.Samples { + if len([]rune(ex)) > 40 { + ex = string([]rune(ex)[:40]) + "…" + } + b.WriteString(fmt.Sprintf(" - \"%s\"\n", ex)) + } + } + return b.String() +} diff --git a/agent/router/matcher.go b/agent/router/matcher.go new file mode 100644 index 0000000..7c7862f --- /dev/null +++ b/agent/router/matcher.go @@ -0,0 +1,117 @@ +package router + +import ( + "sync" + + "deepx/agent" +) + +const complexTaskSimThreshold = 0.6 + +type matcherGroup struct { + Patterns []string + Vectors []map[string]float64 + Decision RouteDecision +} + +type SemanticMatcher struct { + groups []matcherGroup + embedder agent.Embedder + once sync.Once +} + +func NewSemanticMatcher(emb agent.Embedder) *SemanticMatcher { + m := &SemanticMatcher{embedder: emb} + m.initGroups() + return m +} + +func (m *SemanticMatcher) initGroups() { + m.groups = []matcherGroup{ + { + // 简单任务 → flash (无 thinking) + Patterns: LoadSimplePatterns(), + Decision: RouteDecision{Role: "flash", Source: "semantic_simple"}, + }, + { + // 中等复杂度 → flash + thinking: enabled + Patterns: LoadMediumPatterns(), + Decision: RouteDecision{Role: "flash", Thinking: "enabled", Source: "semantic_medium"}, + }, + { + // 复杂任务 → pro + reasoning_effort=medium, 关闭 thinking(两者冗余) + Patterns: LoadComplexPatterns(), + Decision: RouteDecision{Role: "pro", ReasoningEffort: "medium", Thinking: "disabled", Source: "semantic_complex"}, + }, + { + // 极深推理 → pro + reasoning_effort=high, 关闭 thinking(两者冗余) + Patterns: LoadDeepPatterns(), + Decision: RouteDecision{Role: "pro", ReasoningEffort: "high", Thinking: "disabled", Source: "semantic_deep"}, + }, + } +} + +func (m *SemanticMatcher) HasEmbedder() bool { return m.embedder != nil } + +// BestSimilarity 返回输入与所有语义元句的最高相似度。 +func (m *SemanticMatcher) BestSimilarity(text string) float64 { + if m.embedder == nil { + return 0 + } + m.once.Do(func() { + for i := range m.groups { + m.groups[i].Vectors = precomputeVectors(m.embedder, m.groups[i].Patterns) + } + }) + userVec := m.embedder.Embed(text) + if len(userVec) == 0 { + return 0 + } + best := 0.0 + for i := range m.groups { + for _, pVec := range m.groups[i].Vectors { + if len(pVec) == 0 { + continue + } + sim := agent.CosineSimilarity(userVec, pVec) + if sim > best { + best = sim + } + } + } + return best +} + +func (m *SemanticMatcher) Match(text string) *RouteDecision { + if m.embedder == nil { + return nil + } + m.once.Do(func() { + for i := range m.groups { + m.groups[i].Vectors = precomputeVectors(m.embedder, m.groups[i].Patterns) + } + }) + userVec := m.embedder.Embed(text) + if len(userVec) == 0 { + return nil + } + for i := range m.groups { + for _, pVec := range m.groups[i].Vectors { + if len(pVec) == 0 { + continue + } + if agent.CosineSimilarity(userVec, pVec) >= complexTaskSimThreshold { + return &m.groups[i].Decision + } + } + } + return nil +} + +func precomputeVectors(emb agent.Embedder, patterns []string) []map[string]float64 { + vecs := make([]map[string]float64, len(patterns)) + for i, p := range patterns { + vecs[i] = emb.Embed(p) + } + return vecs +} diff --git a/agent/router/patterns.go b/agent/router/patterns.go new file mode 100644 index 0000000..b271dcd --- /dev/null +++ b/agent/router/patterns.go @@ -0,0 +1,83 @@ +package router + +import ( + "os" + "path/filepath" + "strings" +) + +// 默认模式元句,作为文件缺失时的回退。 + +var DefaultSimplePatterns = []string{ + "简单的文件读写操作不需要深度推理", + "查看代码定义和简单的搜索匹配", + "单行或几行的代码修改不用理解全局架构", + "编译运行和测试执行等机械操作", + "简单的配置修改和参数调整", + "查看日志和错误信息不需要深度分析", + "解释简单概念和回答基础知识问题", +} + +var DefaultMediumPatterns = []string{ + "修改单个文件的代码需要理解局部逻辑", + "添加新功能需要理解模块接口", + "讲解代码逻辑和执行流程", + "查找调用链和依赖关系", + "修复普通bug需要理解函数逻辑", +} + +var DefaultComplexPatterns = []string{ + "排查性能瓶颈内存泄漏死锁并发问题并分析根因修复方案", + "跨模块设计接口和抽象层", + "分析系统级调用链和依赖关系", + "重构现有代码架构", + "设计新的模块接口API规范", + "分析复杂系统设计文档架构决策调用链和依赖图并推理因果", + "修复跨模块的复杂bug", +} + +var DefaultDeepPatterns = []string{ + "审查代码质量和安全性查找潜在漏洞边界条件和逻辑错误", + "设计系统级架构和技术选型方案", + "制定完整的模块开发计划和架构决策", + "评估系统级变更的影响范围和风险", + "设计高可用和高性能的系统架构", + "权衡多种技术方案的利弊做出架构决策", +} + +func patternsDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".deepx", "router", "patterns") +} + +func loadPatterns(filename string, defaults []string) []string { + dir := patternsDir() + if dir == "" { + return defaults + } + path := filepath.Join(dir, filename) + data, err := os.ReadFile(path) + if err != nil { + return defaults + } + var out []string + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + out = append(out, line) + } + if len(out) == 0 { + return defaults + } + return out +} + +func LoadSimplePatterns() []string { return loadPatterns("simple.toml", DefaultSimplePatterns) } +func LoadMediumPatterns() []string { return loadPatterns("medium.toml", DefaultMediumPatterns) } +func LoadComplexPatterns() []string { return loadPatterns("complex.toml", DefaultComplexPatterns) } +func LoadDeepPatterns() []string { return loadPatterns("deep.toml", DefaultDeepPatterns) } diff --git a/agent/router/patterns/complex.toml b/agent/router/patterns/complex.toml new file mode 100644 index 0000000..8ee0829 --- /dev/null +++ b/agent/router/patterns/complex.toml @@ -0,0 +1,15 @@ +# 升级模式: pro + reasoning_effort=medium +# 适用于: 问题分析、跨模块修改、重构设计 + +排查性能瓶颈内存泄漏死锁并发问题并分析根因修复方案 +跨模块设计接口和抽象层 +分析系统级调用链和依赖关系 +重构现有代码架构 +设计新的模块接口API规范 +分析复杂系统设计文档架构决策调用链和依赖图并推理因果 +排查智驾功能与座舱系统交互问题 +设计AAOS系统服务与HAL层交互方案 +分析APP到Framework到HAL的完整调用链 +研究技术方案实现策略制定执行计划 +修复跨模块的复杂bug +理解SOME/IP通信协议和VehicleProperty交互机制 diff --git a/agent/router/patterns/deep.toml b/agent/router/patterns/deep.toml new file mode 100644 index 0000000..647e62a --- /dev/null +++ b/agent/router/patterns/deep.toml @@ -0,0 +1,14 @@ +# 极深推理: pro + reasoning_effort=high +# 适用于: 架构设计、安全审查、技术选型 + +审查代码质量和安全性查找潜在漏洞边界条件和逻辑错误 +设计系统级架构和技术选型方案 +设计智驾功能与座舱系统的集成方案 +制定完整的模块开发计划和架构决策 +评估系统级变更的影响范围和风险 +设计高可用和高性能的系统架构 +制定跨团队协作的接口规范 +权衡多种技术方案的利弊做出架构决策 +分析系统安全性并设计防御策略 +设计复杂的并发和同步机制 +规划大规模代码重构的执行路径和回滚方案 diff --git a/agent/router/patterns/medium.toml b/agent/router/patterns/medium.toml new file mode 100644 index 0000000..73a3147 --- /dev/null +++ b/agent/router/patterns/medium.toml @@ -0,0 +1,13 @@ +# 中等复杂度: flash + thinking=enabled +# 适用于: 代码解读、单文件修改、问题定位 + +修改单个文件的代码需要理解局部逻辑 +添加新功能需要理解模块接口 +讲解代码逻辑和执行流程 +查找调用链和依赖关系 +定位编译错误和警告原因 +解读AAOS框架和Android系统服务 +理解Vehicle HAL和SOME/IP通信流程 +分析单一模块的代码结构和设计模式 +修复普通bug需要理解函数逻辑 +理解VariantCoding配置和功能开关 diff --git a/agent/router/patterns/simple.toml b/agent/router/patterns/simple.toml new file mode 100644 index 0000000..f80b624 --- /dev/null +++ b/agent/router/patterns/simple.toml @@ -0,0 +1,13 @@ +# 降级模式: 简单任务,flash 原生级别 +# 适用于: 快速查找、简单解释、单步操作 + +简单的文件读写操作不需要深度推理 +查看代码定义和简单的搜索匹配 +单行或几行的代码修改不用理解全局架构 +编译运行和测试执行等机械操作 +简单的配置修改和参数调整 +查看日志和错误信息不需要深度分析 +解释简单概念和回答基础知识问题 +翻译文档和注释 +查看变量值和类型定义 +执行lint和格式化等代码检查 diff --git a/agent/router/router.go b/agent/router/router.go new file mode 100644 index 0000000..58897fc --- /dev/null +++ b/agent/router/router.go @@ -0,0 +1,79 @@ +package router + +import ( + "deepx/agent" +) + +// Router 是模型路由的入口,组合语义匹配和盲区检测。 +type Router struct { + matcher *SemanticMatcher + detector *BlindSpotDetector + tg *agent.TopicGraph +} + +func NewRouter(tg *agent.TopicGraph) *Router { + if tg == nil { + return nil + } + return &Router{ + matcher: NewSemanticMatcher(tg.Embedder()), + detector: NewBlindSpotDetector(tg.Embedder()), + tg: tg, + } +} + +func (r *Router) Decide(text string) RouteDecision { + if r.matcher != nil && r.matcher.HasEmbedder() { + if d := r.matcher.Match(text); d != nil { + return *d + } + } + if len([]rune(text)) > 500 { + return RouteDecision{Role: "pro", Source: "length", Trace: "超长消息"} + } + return RouteDecision{Role: "flash", Source: "fallback", Trace: "默认"} +} + +// RecordMiss 记录语义匹配遗漏(由 SwitchModel 升级触发)。 +func (r *Router) RecordMiss(input string, _ map[string]float64, _ float64) { + if r.detector == nil { + return + } + // 获取语义匹配的最大相似度 + bestSim := 0.0 + if r.matcher != nil { + bestSim = r.matcher.BestSimilarity(input) + } + // 用嵌入器计算输入向量 + var vec map[string]float64 + if emb := r.tg.Embedder(); emb != nil { + vec = emb.Embed(input) + } + r.detector.Record(input, vec, bestSim, "complex") +} + +// ReviewHint 返回盲区审查提示。 +func (r *Router) ReviewHint() string { + if r.detector == nil { + return "" + } + return r.detector.ReviewHint() +} + +// SetEmbedder 替换嵌入器(用于 ONNX 延迟加载后就绪时切换)。 +func (r *Router) SetEmbedder(emb agent.Embedder) { + if r.matcher != nil { + r.matcher = NewSemanticMatcher(emb) + } + if r.detector != nil { + r.detector = NewBlindSpotDetector(emb) + } +} + +// BestSimilarity 返回输入与最佳语义元句的相似度。 +func (r *Router) BestSimilarity(text string) float64 { + if r.matcher == nil { + return 0 + } + return r.matcher.BestSimilarity(text) +} diff --git a/agent/topic_tracker.go b/agent/topic_tracker.go index f61d243..22894f4 100644 --- a/agent/topic_tracker.go +++ b/agent/topic_tracker.go @@ -111,7 +111,7 @@ func (tg *TopicGraph) updateDocFreq(tokens []string) { // === 相似度 === // cosineSimilarity 计算两个向量的余弦相似度。 -func cosineSimilarity(a, b map[string]float64) float64 { +func CosineSimilarity(a, b map[string]float64) float64 { if len(a) == 0 || len(b) == 0 { return 0 } @@ -174,7 +174,7 @@ func (tg *TopicGraph) TrackMessage(content string, msgIdx int) (topicIdx int, is bestTopic := -1 bestScore := 0.0 for i := range tg.Topics { - score := cosineSimilarity(vec, tg.Topics[i].Vector) + score := CosineSimilarity(vec, tg.Topics[i].Vector) if score > bestScore { bestScore = score bestTopic = i @@ -365,7 +365,7 @@ func (tg *TopicGraph) TopicSwitched(minMsgs int) (switched bool, oldKW, newKW [] // 计算会话重心向量(全部话题的加权平均, 权重 = 消息数) centroid := tg.sessionCentroid() // 当前话题与重心向量的语义相似度 - sim := cosineSimilarity(tg.Topics[cur].Vector, centroid) + sim := CosineSimilarity(tg.Topics[cur].Vector, centroid) if sim >= topicExtensionSim { return false, nil, nil // 主题扩展, 不提示 } @@ -428,7 +428,7 @@ func (tg *TopicGraph) RelevanceTo(msgIdx int) float64 { } return 0 } - return cosineSimilarity(tg.Topics[cur].Vector, tg.Topics[target].Vector) + return CosineSimilarity(tg.Topics[cur].Vector, tg.Topics[target].Vector) } // SessionFocus 返回当前会话侧重点的摘要描述。 @@ -493,6 +493,11 @@ func (tg *TopicGraph) EmbedderName() string { return "tfidf" } +// Embedder 返回嵌入器实例(供外部包使用)。 +func (tg *TopicGraph) Embedder() Embedder { + return tg.embedder +} + // === 发送前偏离检测 === // DriftDetectThreshold 是"发送前偏离检测"的相似度阈值。 @@ -523,5 +528,17 @@ func (tg *TopicGraph) SimilarityToSession(text string) float64 { if len(centroid) == 0 { return 1.0 // 无重心 → 允许任意输入 } - return cosineSimilarity(vec, centroid) + return CosineSimilarity(vec, centroid) +} + +// SetEmbedder 替换嵌入器(用于 ONNX 延迟加载后就绪时切换)。 +func (tg *TopicGraph) SetEmbedder(emb Embedder) { + tg.embedder = emb +} + +// ResetTopics 清除所有主题向量, 用于嵌入器切换后重新聚类。 +func (tg *TopicGraph) ResetTopics() { + for i := range tg.Topics { + tg.Topics[i].Vector = nil + } } diff --git a/agent/topic_tracker_test.go b/agent/topic_tracker_test.go index 05c3471..1a59c9d 100644 --- a/agent/topic_tracker_test.go +++ b/agent/topic_tracker_test.go @@ -65,7 +65,7 @@ func TestTrackMessageSameTopic(t *testing.T) { t.Fatal("first message should create new topic") } - idx2, isNew2 := tg.TrackMessage("把 context_window 也改大一些", 1) + idx2, isNew2 := tg.TrackMessage("把 max_tokens 配置再改大一些", 1) if isNew2 { t.Fatal("similar topic should not create new topic") } @@ -175,13 +175,13 @@ 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) + 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) + sim2 := CosineSimilarity(a, c) if sim2 > 0.01 { t.Errorf("disjoint vectors should have similarity ~0, got %f", sim2) } diff --git a/tui/exec.go b/tui/exec.go index 267b2fe..b9d3300 100644 --- a/tui/exec.go +++ b/tui/exec.go @@ -52,7 +52,7 @@ func RunExec(cfg agent.ModelConfig, prompt string) error { // 固定 auto 模式;forceRole 传 "auto" → 走本地关键词路由(零 token 决定起手模型)。 // summary 空(一次性,无压缩)。 - _, ch := agent.StartStream(ctx, cfg, history, agent.AgentMode_Auto, wd, skillCatalog, "", "auto", agent.WorkingModeDefault) + _, ch := agent.StartStream(ctx, cfg, history, agent.AgentMode_Auto, wd, skillCatalog, "", "auto", agent.WorkingModeDefault, "", "") var streamErr error for msg := range ch { diff --git a/tui/model.go b/tui/model.go index e6df8ab..4f98229 100644 --- a/tui/model.go +++ b/tui/model.go @@ -3,6 +3,7 @@ package tui import ( "context" "deepx/agent" + "deepx/agent/router" "deepx/config" "deepx/mcp" "deepx/session" @@ -310,6 +311,14 @@ type model struct { // topicGraph 是本地主题追踪图, 追踪对话主题的演化。 // 每轮 user 消息后自动更新, 供压缩时策略性选择保留内容(Phase 2)。 topicGraph *agent.TopicGraph + // router 是模型路由决策器, 组合语义/上下文/反馈分析。 + router *router.Router + // reasoningEffort 本轮路由决策的推理深度(从 RouteDecision 提取)。 + reasoningEffort string + // thinking 本轮路由决策的 thinking 模式(从 RouteDecision 提取)。 + routeThinking string + // lastModelSwitch 最近一次模型切换原因(用于右栏显示)。 + lastModelSwitch string // lastFocusID 上一次焦点话题 ID, 用于 FocusChanged 检测。 lastFocusID int @@ -718,19 +727,36 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub var segmenter agent.Segmenter var segErr error var embedder agent.Embedder + var topicGraph *agent.TopicGraph + var route *router.Router if segCfg != nil && segCfg.TopicTracking { // 创建分词器(中文词典, 用于 TF-IDF 回退) segCacheDir, _ := config.SegmenterDir() segmenter, segErr = agent.NewSegmenter("zh", segCacheDir) // 创建嵌入器 - if segCfg.Embedder != "" { + if segCfg.Embedder != "" && segCfg.Embedder != "tfidf" { + // ONNX 延迟加载: 启动时用 TF-IDF, 后台加载 ONNX embCacheDir, _ := config.SegmenterDir() - embedder, _ = agent.NewEmbedder(agent.EmbedderType(segCfg.Embedder), embCacheDir, segCfg.ONNXModel) + embedder = agent.NewTFIDFEmbedder() + // 后台加载 ONNX 模型, 就绪后通过 SetEmbedder 切换 + go func() { + onnxEmb, err := agent.NewEmbedder(agent.EmbedderType(segCfg.Embedder), embCacheDir, segCfg.ONNXModel) + if err == nil && onnxEmb != nil { + if topicGraph != nil { + topicGraph.SetEmbedder(onnxEmb) + // 重建主题向量: 清除旧 TF-IDF 向量, 后续消息用 ONNX 重新聚类 + topicGraph.ResetTopics() + } + if route != nil { + route.SetEmbedder(onnxEmb) + } + } + }() } } - var topicGraph *agent.TopicGraph if segmenter != nil { topicGraph = agent.NewTopicGraph(segmenter, embedder) + route = router.NewRouter(topicGraph) } m := model{ @@ -765,6 +791,7 @@ func initialModel(models agent.ModelConfig, needsSetup bool, version string, hub srv: srv, webURL: webURL, topicGraph: topicGraph, + router: route, inputHistoryIndex: -1, } @@ -1269,9 +1296,25 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { // 测试模式: 输出发送给 AI 前的处理信息 if m.testMode { + // 获取路由决策信息 + routeLevel := "未路由" + routeSource := "" + routeReason := "" + if m.router != nil { + dec := m.router.Decide(input) + routeLevel = dec.Role + routeSource = dec.Source + routeReason = dec.Trace + if dec.ReasoningEffort != "" { + routeLevel += " + reasoning_effort=" + dec.ReasoningEffort + } + } m.appendChat("System", fmt.Sprintf( "🧪 **测试模式 - 请求前分析**\n\n"+ "**模型路由**: %s → %s\n"+ + "**路由等级**: %s\n"+ + "**路由来源**: %s\n"+ + "**路由理由**: %s\n"+ "**工作模式**: %s\n"+ "**会话摘要**: %s\n"+ "**历史消息数**: %d 轮\n"+ @@ -1279,6 +1322,9 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { "**嵌入器**: %s\n"+ "**发送前偏离检测**: 相似度 %.0f%%, 阈值 %.0f%%, %s", m.activeModelRole, m.activeModelID, + routeLevel, + routeSource, + routeReason, m.workingMode, truncTitle(m.summary, 60), len(m.history), @@ -1290,7 +1336,6 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { if cur < 0 { return "无主题" } - // ONNX 模式下: 显示会话名称 if strings.HasPrefix(m.topicGraph.EmbedderName(), "onnx") { if title := m.session.ConvTitle(); title != "" { return truncTitle(title, 30) @@ -1323,6 +1368,10 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { return "✅ 正常" }(), )) + // 盲区审查提示 + if hint := m.router.ReviewHint(); hint != "" { + m.appendChat("System", hint) + } } var cmds []tea.Cmd @@ -1331,7 +1380,15 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { // 上下文感知路由: 结合关键词 + 会话上下文决定起手模型。 forceRole := "" if m.modelPin == "" || m.modelPin == "auto" { - role := agent.RouteWithContext(input, m.topicGraph) + role := "" + if m.router != nil { + dec := m.router.Decide(input) + role = dec.Role + m.reasoningEffort = dec.ReasoningEffort + m.routeThinking = dec.Thinking + } else { + role = agent.RouteByKeyword(input) + } forceRole = role m.activeModelRole = role if role == "pro" { @@ -1396,6 +1453,8 @@ func (m model) submitUserInput(input string) (model, tea.Cmd) { m.summary, effectiveRole, m.workingMode, + m.reasoningEffort, + m.routeThinking, ) m.streamCh = ch cmds = append(cmds, cmd) @@ -2530,7 +2589,23 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streaming = false m.thinking = false m.compactingInTurn = false // 兜底:轮内压缩的复位消息可能被 drainAndDiscard 丢掉 - m.status = "idle" + // 任务结束: 输出模型升级汇总(如有) + if m.lastModelSwitch != "" { + if m.testMode { + m.chatContent.Append(fmt.Sprintf("\n[路由] %s\n", m.lastModelSwitch)) + } else { + m.chatContent.Append(fmt.Sprintf("\n[路由] %s\n", m.lastModelSwitch)) + } + m.refreshViewport() + } + // 盲区审查提示(测试模式) + if m.testMode && m.router != nil { + if hint := m.router.ReviewHint(); hint != "" { + m.chatContent.Append(hint + "\n") + m.refreshViewport() + } + } + m.status = "idle" m.chatContent.Append(T("misc.interrupted")) m.queuedInput = nil // 中止本轮 → 丢弃排队消息,不再自动续发 m.broadcastQueued() // 同步清空 web 待发送区 @@ -3083,12 +3158,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.streamCh == nil { return m, nil } + oldRole := m.activeModelRole m.activeModelRole = msg.Role m.activeModelID = msg.ModelID - if msg.Reason != "" { - // 升级类的切换在聊天流里留一行可见痕迹,便于用户察觉为什么变贵了 - m.chatContent.Append(fmt.Sprintf("\n[已升级到 %s 模型] 原因: %s\n", msg.Role, msg.Reason)) - m.refreshViewport() + // 模型升级: 记录原因(LLM 未提供时自动评估) + reason := msg.Reason + if reason == "" { + reason = m.evalUpgradeReason() + } + m.lastModelSwitch = fmt.Sprintf("%s→%s: %s", oldRole, msg.Role, reason) + // 语义匹配盲区: 仅当升级由任务复杂度导致时记录 + if m.router != nil && m.topicGraph != nil && !msg.CompressNow { + m.router.RecordMiss(m.pendingUserText, nil, 0) } return m, agent.ListenToStream(m.streamCh) @@ -3163,6 +3244,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.applyTurnTopic(ctxWin) + // 任务结束: 输出模型升级汇总(如有) + if m.lastModelSwitch != "" { + if m.testMode { + m.chatContent.Append(fmt.Sprintf("\n[路由] %s\n", m.lastModelSwitch)) + } else { + m.chatContent.Append(fmt.Sprintf("\n[路由] %s\n", m.lastModelSwitch)) + } + m.refreshViewport() + } + // 盲区审查提示(测试模式) + if m.testMode && m.router != nil { + if hint := m.router.ReviewHint(); hint != "" { + m.chatContent.Append(hint + "\n") + m.refreshViewport() + } + } m.status = "idle" m.streaming = false m.thinking = false @@ -4653,3 +4750,43 @@ func (m *model) navigateHistoryDown() { m.inputDraft = "" } } + +// isContextStressUpgrade 判断 SwitchModel 升级是否因上下文压力导致。 +// 上下文压力理由含 "窗口"、"context"、"70%" 等关键词。 + +// evalUpgradeReason 评估模型升级原因(LLM 未提供 reason 时自动计算)。 +// 基于上下文使用率 + 语义匹配度 + 历史轮数综合判断。 +func (m *model) evalUpgradeReason() string { + // 上下文使用率 + ctxUsage := m.contextUsagePct() + // 语义匹配度(与最佳语义元句的相似度) + semSim := 0.0 + if m.router != nil { + semSim = m.router.BestSimilarity(m.pendingUserText) + } + // 历史轮数 + turns := len(m.history) + + // 上下文压力: 使用率高 + 轮数多 + if ctxUsage > 60 || turns > 100 { + return fmt.Sprintf("上下文压力(使用率=%.0f%%, 轮数=%d, 语义匹配=%.0f%%)", ctxUsage, turns, semSim*100) + } + // 任务复杂度: 语义匹配度低 + 轮数多(长任务) + if semSim < 0.5 && turns > 20 { + return fmt.Sprintf("任务复杂度高(语义匹配=%.0f%%, 轮数=%d, 上下文=%.0f%%)", semSim*100, turns, ctxUsage) + } + // 混合: 两者兼有 + return fmt.Sprintf("综合因素(语义=%.0f%%, 上下文=%.0f%%, 轮数=%d)", semSim*100, ctxUsage, turns) +} + +// contextUsagePct 估算当前上下文使用百分比。 +func (m *model) contextUsagePct() float64 { + if m.models.Pro.ContextWindow <= 0 { + return 0 + } + est := 0 + for _, h := range m.history { + est += agent.MsgTokens(h) + } + return float64(est) / float64(m.models.Pro.ContextWindow) * 100 +} diff --git a/tui/view.go b/tui/view.go index c198001..836be8b 100644 --- a/tui/view.go +++ b/tui/view.go @@ -940,6 +940,10 @@ func (m model) rightPanelView() string { rows = append(rows, section(T("panel.curmodel"), []string{ truncate(curModel, rightPanelWidth-4), })...) + // 模型切换原因: 始终显示(空时跳过) + if m.lastModelSwitch != "" { + rows = append(rows, inlineRow("切换:", lipgloss.NewStyle().Foreground(highlightColor).Render(truncate(m.lastModelSwitch, rightPanelWidth-8))), "") + } // 会话主题:模型每轮结尾自报(见 topic.go),按轮更新。摆在上下文用量上面 —— 这两项是 // 一起看的:主题告诉你"这段对话在讲什么",用量告诉你"它占了多少",主题变了而用量又高 // 就该 /new 了。还没识别到时用暗色占位,保持右栏行数稳定、不跳动。