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 }