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