From ac966af3d126d59081d119e05ba02f946257f669 Mon Sep 17 00:00:00 2001
From: ai_xiaopei <xiaopei@aisim.cn>
Date: Sun, 26 Jul 2026 23:22:36 +0800
Subject: [PATCH] fix: 移除所有help中的等号格式
---
internal/llm/client.go | 406 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 406 insertions(+), 0 deletions(-)
diff --git a/internal/llm/client.go b/internal/llm/client.go
new file mode 100644
index 0000000..a4a52bf
--- /dev/null
+++ b/internal/llm/client.go
@@ -0,0 +1,406 @@
+package llm
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+
+ "gopkg.in/yaml.v3"
+)
+
+// LLMConfig 单个 LLM 配置
+type LLMConfig struct {
+ APIBase string `yaml:"api_base"`
+ APIKey string `yaml:"api_key"`
+ Model string `yaml:"model"`
+ Temperature float64 `yaml:"temperature"`
+ MaxTokens int `yaml:"max_tokens"`
+ DisableThinking bool `yaml:"disable_thinking"`
+}
+
+// Config 配置文件结构
+type Config struct {
+ LLM struct {
+ Primary LLMConfig `yaml:"primary"`
+ Fallback *LLMConfig `yaml:"fallback"`
+ } `yaml:"llm"`
+ KnowledgeBase struct {
+ VaultPath string `yaml:"vault_path"`
+ DBPath string `yaml:"db_path"`
+ } `yaml:"knowledge_base"`
+ Draft struct {
+ ReviewDir string `yaml:"review_dir"`
+ DefaultType string `yaml:"default_type"`
+ } `yaml:"draft"`
+}
+
+// Client LLM 客户端
+type Client struct {
+ config Config
+ usePrimary bool
+}
+
+// ExtractResult 提取结果
+type ExtractResult struct {
+ Tags []string `json:"tags"`
+ RelatedDocs []string `json:"related_docs"`
+}
+
+// MergeHint 合并指示
+type MergeHint struct {
+ Recommendation struct {
+ Action string `json:"action"`
+ Target string `json:"target"`
+ Reason string `json:"reason"`
+ Confidence string `json:"confidence"`
+ } `json:"recommendation"`
+ Analysis []struct {
+ Path string `json:"path"`
+ Relevance string `json:"relevance"`
+ Reason string `json:"reason"`
+ } `json:"analysis"`
+}
+
+// SearchCandidate 搜索候选
+type SearchCandidate struct {
+ Title string `json:"title"`
+ Path string `json:"path"`
+ Score float64 `json:"score"`
+}
+
+// NewClient 创建 LLM 客户端
+func NewClient() *Client {
+ config := loadConfig()
+ return &Client{config: config, usePrimary: true}
+}
+
+// loadConfig 加载配置文件
+func loadConfig() Config {
+ var config Config
+
+ // 配置文件路径
+ homeDir, _ := os.UserHomeDir()
+ configPath := filepath.Join(homeDir, ".kb-cli", "config.yaml")
+
+ // 读取配置文件
+ data, err := os.ReadFile(configPath)
+ if err != nil {
+ // 如果配置文件不存在,使用默认值
+ fmt.Fprintf(os.Stderr, "警告: 无法读取配置文件 %s,使用默认值\n", configPath)
+ return getDefaultConfig()
+ }
+
+ // 解析 YAML
+ if err := yaml.Unmarshal(data, &config); err != nil {
+ fmt.Fprintf(os.Stderr, "警告: 解析配置文件失败: %v,使用默认值\n", err)
+ return getDefaultConfig()
+ }
+
+ // 展开 ~ 路径
+ config.LLM.Primary.APIBase = expandPath(config.LLM.Primary.APIBase)
+ config.KnowledgeBase.VaultPath = expandPath(config.KnowledgeBase.VaultPath)
+ config.KnowledgeBase.DBPath = expandPath(config.KnowledgeBase.DBPath)
+ if config.LLM.Fallback != nil {
+ config.LLM.Fallback.APIBase = expandPath(config.LLM.Fallback.APIBase)
+ }
+
+ return config
+}
+
+// getDefaultConfig 获取默认配置
+func getDefaultConfig() Config {
+ var config Config
+ config.LLM.Primary.APIBase = "http://192.168.3.246:1127/v1"
+ config.LLM.Primary.APIKey = "sk-local"
+ config.LLM.Primary.Model = "qwen3.6-35b-a3b"
+ config.LLM.Primary.Temperature = 0.3
+ config.LLM.Primary.MaxTokens = 2000
+ config.LLM.Primary.DisableThinking = true
+ config.KnowledgeBase.VaultPath = "~/aisim/note/001/笔记001"
+ config.KnowledgeBase.DBPath = "~/.cache/kb-cli/kb.db"
+ config.Draft.ReviewDir = "待审阅"
+ config.Draft.DefaultType = "售后"
+ return config
+}
+
+// expandPath 展开路径中的 ~
+func expandPath(path string) string {
+ if strings.HasPrefix(path, "~/") {
+ homeDir, _ := os.UserHomeDir()
+ return filepath.Join(homeDir, path[2:])
+ }
+ return path
+}
+
+// getCurrentConfig 获取当前使用的 LLM 配置
+func (c *Client) getCurrentConfig() LLMConfig {
+ if c.usePrimary {
+ return c.config.LLM.Primary
+ }
+ if c.config.LLM.Fallback != nil {
+ return *c.config.LLM.Fallback
+ }
+ return c.config.LLM.Primary
+}
+
+// switchToNext 切换到下一个可用的 LLM
+func (c *Client) switchToNext() bool {
+ if c.usePrimary && c.config.LLM.Fallback != nil {
+ fmt.Fprintf(os.Stderr, "主 LLM 不可用,切换到备用 LLM\n")
+ c.usePrimary = false
+ return true
+ }
+ return false
+}
+
+// ExtractTags 提取 tags 和相关文档
+func (c *Client) ExtractTags(content string) (*ExtractResult, error) {
+ prompt := fmt.Sprintf(`分析以下知识库草稿,提取:
+1. tags(5-10个):
+ - 核心问题标签(如"充不进气"、"档案下载失败")
+ - 扩展词(不同人可能的描述,如"充气慢"、"进气不足")
+ - 平台/设备标签(如"电子秤平台"、"智能枪")
+2. related_docs(0-5个):相关文档标题(用于创建链接)
+
+输出 JSON:
+{
+ "tags": ["充不进气", "充气慢", "进气不足", "智能枪", "电子秤平台"],
+ "related_docs": ["智能枪通气杆卡住漏气", "角阀充装功率不足"]
+}
+
+草稿内容:
+%s`, content)
+
+ response, err := c.callLLMWithRetry(prompt)
+ if err != nil {
+ return nil, err
+ }
+
+ // 解析 JSON
+ var result ExtractResult
+ if err := parseJSON(response, &result); err != nil {
+ return nil, fmt.Errorf("解析 LLM 响应失败: %w", err)
+ }
+
+ return &result, nil
+}
+
+// GenerateMergeHint 生成合并指示
+func (c *Client) GenerateMergeHint(draftContent string, candidates []SearchCandidate) (*MergeHint, error) {
+ prompt := fmt.Sprintf(`你是一个知识库管理助手。请判断以下草稿与哪些知识库文档相关。
+
+## 草稿内容
+%s
+
+## 候选文档(Top 3)
+`, draftContent)
+
+ for i, cand := range candidates {
+ prompt += fmt.Sprintf("%d. 标题: %s, 路径: %s, 相关度: %.2f\n", i+1, cand.Title, cand.Path, cand.Score)
+ }
+
+ prompt += `
+## 任务
+请分析草稿与每个候选文档的相关性,输出 JSON 格式:
+
+{
+ "recommendation": {
+ "action": "merge|new|split",
+ "target": "目标路径(如果 action=merge)",
+ "reason": "判断理由",
+ "confidence": "high|medium|low"
+ },
+ "analysis": [
+ {
+ "path": "文档路径",
+ "relevance": "high|medium|low",
+ "reason": "相关性说明"
+ }
+ ]
+}
+
+判断标准:
+- action=merge: 草稿内容与某个候选文档高度相关,应该合并
+- action=new: 草稿内容是全新的,应该新建文档
+- action=split: 草稿内容包含多个独立主题,应该拆分
+- confidence=high: 判断很确定
+- confidence=medium: 判断比较确定
+- confidence=low: 判断不太确定
+
+只输出 JSON,不要其他内容。`
+
+ response, err := c.callLLMWithRetry(prompt)
+ if err != nil {
+ return nil, err
+ }
+
+ var result MergeHint
+ if err := parseJSON(response, &result); err != nil {
+ return nil, fmt.Errorf("解析 LLM 响应失败: %w", err)
+ }
+
+ return &result, nil
+}
+
+// ClassifyDocuments 分类文档
+func (c *Client) ClassifyDocuments(prompt string) (string, error) {
+ response, err := c.callLLMWithRetry(prompt)
+ if err != nil {
+ return "", err
+ }
+
+ // 解析 JSON
+ var result []struct {
+ Path string `json:"path"`
+ Platform string `json:"platform"`
+ Device string `json:"device"`
+ ContentType string `json:"content_type"`
+ Confidence float64 `json:"confidence"`
+ }
+
+ if err := parseJSON(response, &result); err != nil {
+ return "", fmt.Errorf("解析 LLM 响应失败: %w", err)
+ }
+
+ // 转换回 JSON 字符串
+ jsonBytes, err := json.Marshal(result)
+ if err != nil {
+ return "", err
+ }
+
+ return string(jsonBytes), nil
+}
+
+// ClassifyDocuments 分类文档
+
+// callLLMWithRetry 带重试的 LLM 调用
+func (c *Client) callLLMWithRetry(prompt string) (string, error) {
+ // 第一次尝试
+ response, err := c.callLLM(prompt)
+ if err == nil {
+ return response, nil
+ }
+
+ // 判断是否是需要切换的错误
+ if shouldSwitchLLM(err) {
+ // 尝试切换到备用 LLM
+ if c.switchToNext() {
+ fmt.Fprintf(os.Stderr, "重试备用 LLM...\n")
+ response, err = c.callLLM(prompt)
+ if err == nil {
+ return response, nil
+ }
+ }
+ }
+
+ return "", err
+}
+
+// shouldSwitchLLM 判断是否应该切换到备用 LLM
+func shouldSwitchLLM(err error) bool {
+ errMsg := err.Error()
+ // 网络错误、超时、认证失败等应该切换
+ return strings.Contains(errMsg, "connection") ||
+ strings.Contains(errMsg, "timeout") ||
+ strings.Contains(errMsg, "401") ||
+ strings.Contains(errMsg, "403") ||
+ strings.Contains(errMsg, "500") ||
+ strings.Contains(errMsg, "502") ||
+ strings.Contains(errMsg, "503") ||
+ strings.Contains(errMsg, "504")
+}
+
+// callLLM 调用 LLM API
+func (c *Client) callLLM(prompt string) (string, error) {
+ llmConfig := c.getCurrentConfig()
+ url := llmConfig.APIBase + "/chat/completions"
+
+ payload := map[string]interface{}{
+ "model": llmConfig.Model,
+ "messages": []map[string]string{
+ {"role": "user", "content": prompt},
+ },
+ "temperature": llmConfig.Temperature,
+ "max_tokens": llmConfig.MaxTokens,
+ }
+
+ // 如果需要禁用思考模式
+ if llmConfig.DisableThinking {
+ payload["chat_template_kwargs"] = map[string]bool{
+ "enable_thinking": false,
+ }
+ }
+
+ jsonData, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("序列化请求失败: %w", err)
+ }
+
+ req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
+ if err != nil {
+ return "", fmt.Errorf("创建请求失败: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ if llmConfig.APIKey != "" {
+ req.Header.Set("Authorization", "Bearer "+llmConfig.APIKey)
+ }
+
+ client := &http.Client{Timeout: 60 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("请求失败: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("读取响应失败: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("API 返回错误状态: %d, body: %s", resp.StatusCode, string(body))
+ }
+
+ // 解析响应
+ var result struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+
+ if err := json.Unmarshal(body, &result); err != nil {
+ return "", fmt.Errorf("解析响应失败: %w", err)
+ }
+
+ if len(result.Choices) == 0 {
+ return "", fmt.Errorf("API 返回空结果")
+ }
+
+ return result.Choices[0].Message.Content, nil
+}
+
+// parseJSON 解析 JSON(支持 markdown 代码块)
+func parseJSON(content string, v interface{}) error {
+ // 尝试提取 ```json 代码块
+ re := regexp.MustCompile("(?s)```json\\s*(.*?)\\s*```")
+ matches := re.FindStringSubmatch(content)
+ if len(matches) > 1 {
+ content = matches[1]
+ }
+
+ if err := json.Unmarshal([]byte(content), v); err != nil {
+ return err
+ }
+
+ return nil
+}
--
Gitblit v1.9.1