diff --git a/ai.go b/ai.go index 38f7a90..4538d81 100644 --- a/ai.go +++ b/ai.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "path/filepath" "strings" "time" ) @@ -37,7 +38,19 @@ type deepseekResponse struct { } `json:"error,omitempty"` } -// ---------- Deepseek AI 增强匹配 ---------- +// maxPromptBChars 限制 B 表数据在 prompt 中的最大字符数 +const maxPromptBChars = 80000 + +// ---------- AI API 调用 ---------- + +// buildRowCacheKey 为单行匹配构建缓存键 +func (a *App) buildRowCacheKey(matchValue, timeStr string, config MatchConfig) string { + parts := fmt.Sprintf("%s|%s|%s|%.1f|%s", + matchValue, timeStr, config.RegexPattern, config.TimeWindow, + filepath.Base(config.FileBPath)) + h := sha256.Sum256([]byte(parts)) + return hex.EncodeToString(h[:]) +} // hashPrompt 对 prompt 消息计算 SHA256(用于缓存键) func hashPrompt(messages []deepseekMessage) string { @@ -51,10 +64,23 @@ func hashPrompt(messages []deepseekMessage) string { return hex.EncodeToString(h.Sum(nil)) } -// callDeepseekAPI 调用 Deepseek Chat API(带缓存) -func (a *App) callDeepseekAPI(messages []deepseekMessage) (string, error) { - if a.deepseekKey == "" { - return "", fmt.Errorf("请先设置 Deepseek API 密钥") +// callAIAPI 调用 OpenAI 兼容 API(Deepseek / OpenAI / 本地模型 等) +func (a *App) callAIAPI(messages []deepseekMessage) (string, error) { + if a.apiKey == "" { + return "", fmt.Errorf("请先设置 AI API 密钥") + } + + // 默认值 + endpoint := strings.TrimRight(a.apiEndpoint, "/") + if endpoint == "" { + endpoint = "https://api.deepseek.com/v1/chat/completions" + } else if !strings.HasSuffix(endpoint, "/chat/completions") { + // 自动补齐 OpenAI 兼容路径(用户只需填 base URL) + endpoint += "/v1/chat/completions" + } + model := a.apiModel + if model == "" { + model = deepseekModel } hash := hashPrompt(messages) @@ -64,43 +90,45 @@ func (a *App) callDeepseekAPI(messages []deepseekMessage) (string, error) { fmt.Printf("[CACHE] ✓ 命中 AI 缓存 (hash=%s)\n", hash[:12]) return cached, nil } - fmt.Printf("[CACHE] ✗ 缓存未命中 (hash=%s),调用 API...\n", hash[:12]) + fmt.Printf("[CACHE] ✗ 缓存未命中 (hash=%s),调用 %s...\n", hash[:12], endpoint) reqBody := deepseekRequest{ - Model: deepseekModel, + Model: model, Messages: messages, Temperature: deepseekTemperature, MaxTokens: deepseekMaxTokens, } bodyBytes, _ := json.Marshal(reqBody) - httpReq, err := http.NewRequest("POST", "https://api.deepseek.com/v1/chat/completions", - bytes.NewReader(bodyBytes)) + httpReq, err := http.NewRequest("POST", endpoint, bytes.NewReader(bodyBytes)) if err != nil { return "", fmt.Errorf("创建请求失败: %v", err) } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+a.deepseekKey) + httpReq.Header.Set("Authorization", "Bearer "+a.apiKey) - client := &http.Client{Timeout: 60 * time.Second} + client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(httpReq) if err != nil { - return "", fmt.Errorf("调用 Deepseek API 失败: %v", err) + return "", fmt.Errorf("调用 AI API 失败: %v", err) } defer resp.Body.Close() - respBytes, _ := io.ReadAll(resp.Body) + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取 AI 响应失败: %v", err) + } var dr deepseekResponse if err := json.Unmarshal(respBytes, &dr); err != nil { - return "", fmt.Errorf("解析 Deepseek 响应失败: %v", err) + return "", fmt.Errorf("解析 AI 响应失败: %v", err) } if dr.Error != nil { - return "", fmt.Errorf("Deepseek API 错误: %s", dr.Error.Message) + return "", fmt.Errorf("AI API 错误: %s", dr.Error.Message) } if len(dr.Choices) == 0 { - return "", fmt.Errorf("Deepseek 未返回有效结果") + return "", fmt.Errorf("AI 未返回有效结果") } result := strings.TrimSpace(dr.Choices[0].Message.Content) @@ -138,7 +166,8 @@ func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConf } sb.WriteString(fmt.Sprintf("\nB 表参考数据(共 %d 条):\n", len(bRows))) - for _, row := range bRows { + truncated := false + for i, row := range bRows { matchVal := getCell(row, config.ColBMatchIndex) extractVal := getCell(row, config.ColBExtractIndex) sb.WriteString(fmt.Sprintf(" 「%s」 → 目标列值: 「%s」", matchVal, extractVal)) @@ -146,6 +175,15 @@ func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConf sb.WriteString(fmt.Sprintf(", 时间=%s", getCell(row, config.ColBTimeIndex))) } sb.WriteString("\n") + // 限制 B 表部分总字符数,防止 prompt 超出 token 限制 + if sb.Len() > maxPromptBChars { + fmt.Printf("[AI-WARN] Prompt B 表数据超长 (%d 条,%d 字符),截断于第 %d 条\n", len(bRows), sb.Len(), i) + truncated = true + } + if truncated { + sb.WriteString(fmt.Sprintf(" ... 已截断,省略 %d 条\n", len(bRows)-i-1)) + break + } } sb.WriteString("\n请返回 JSON 格式的匹配结果。") @@ -177,3 +215,20 @@ func formatTimeDiff(d time.Duration) string { } return fmt.Sprintf("%s%ds", sign, secs) } + +// parseTimeDiffDuration 将 TimeDiff 字符串(如 "1h30m")解析为 time.Duration(用于排序) +func parseTimeDiffDuration(s string) time.Duration { + if s == "" { + return 0 + } + sign := time.Duration(1) + if s[0] == '-' { + sign = -1 + s = s[1:] + } + d, err := time.ParseDuration(s) + if err != nil { + return 0 + } + return sign * d +} diff --git a/app.go b/app.go index 82db7b8..0e3bd98 100644 --- a/app.go +++ b/app.go @@ -6,9 +6,9 @@ import ( "fmt" "math" "path/filepath" - "regexp" "sort" "strings" + "sync" "time" "github.com/wailsapp/wails/v2/pkg/runtime" @@ -97,12 +97,26 @@ type MatchConfig struct { IncludeHeader bool `json:"includeHeader"` // 导出时是否包含表头行 } +// AICacheInfo 缓存状态信息 +type AICacheInfo struct { + Count int `json:"count"` + FilePath string `json:"filePath"` +} + // ---------- App 结构体 ---------- type App struct { ctx context.Context - deepseekKey string + apiKey string // AI API 密钥(兼容 OpenAI/Deepseek/本地模型) + apiEndpoint string // API 端点(默认 https://api.deepseek.com/v1/chat/completions) + apiModel string // 模型名称(默认 deepseek-chat) aiCache *AICache + + // 最近一次匹配的配置和表头(供导出使用) + dataMu sync.RWMutex + lastConfig MatchConfig + headersA []string + headersB []string } // NewApp 创建 App 实例 @@ -132,18 +146,52 @@ func (a *App) emitProgress(current, total int, message, phase string) { }) } -// SetDeepseekAPIKey 设置 Deepseek API 密钥(仅保存在内存中) +// ---------- AI 配置 ---------- + +// SetDeepseekAPIKey 设置 Deepseek API 密钥(仅保存在内存中,向后兼容) func (a *App) SetDeepseekAPIKey(key string) string { - a.deepseekKey = strings.TrimSpace(key) - if a.deepseekKey == "" { + a.apiKey = strings.TrimSpace(key) + if a.apiKey == "" { return "已清除 Deepseek API 密钥" } return "Deepseek API 密钥已设置" } +// SetAIConfig 统一设置 AI API 配置(端点、模型、密钥) +func (a *App) SetAIConfig(endpoint, model, key string) string { + if endpoint != "" { + a.apiEndpoint = strings.TrimSpace(endpoint) + } + if model != "" { + a.apiModel = strings.TrimSpace(model) + } + if key != "" { + a.apiKey = strings.TrimSpace(key) + } + return fmt.Sprintf("AI 配置已更新 (端点=%s, 模型=%s)", a.apiEndpoint, a.apiModel) +} + +// SetAPIKey 设置 AI API 密钥(仅保存在内存中) +func (a *App) SetAPIKey(key string) string { + a.apiKey = strings.TrimSpace(key) + if a.apiKey == "" { + return "已清除 AI API 密钥" + } + return "AI API 密钥已设置" +} + // GetDeepseekStatus 返回是否已配置 Deepseek API 密钥 func (a *App) GetDeepseekStatus() bool { - return a.deepseekKey != "" + return a.apiKey != "" +} + +// GetAIStatus 返回 AI API 配置状态 +func (a *App) GetAIStatus() map[string]string { + return map[string]string{ + "ready": fmt.Sprintf("%v", a.apiKey != ""), + "endpoint": a.apiEndpoint, + "model": a.apiModel, + } } // ClearAICache 清除所有 AI 缓存 @@ -154,12 +202,9 @@ func (a *App) ClearAICache() string { } // GetAICacheInfo 返回 AI 缓存信息(条目数、文件路径) -func (a *App) GetAICacheInfo() map[string]interface{} { +func (a *App) GetAICacheInfo() AICacheInfo { count, path := a.aiCache.stat() - return map[string]interface{}{ - "count": count, - "filePath": path, - } + return AICacheInfo{Count: count, FilePath: path} } // ---------- 文件选择对话框 ---------- @@ -212,54 +257,17 @@ func (a *App) ParseHeaders(filePath string) ([]string, error) { // RunMatch 接收完整 MatchConfig,按列索引执行通用匹配 func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { - // 1. 编译正则 - var reg *regexp.Regexp - if config.RegexPattern != "" { - var err error - reg, err = regexp.Compile(config.RegexPattern) - if err != nil { - return nil, fmt.Errorf("正则表达式格式错误,请检查: %v", err) - } - fmt.Printf("[DEBUG] RunMatch 使用正则: '%s'\n", config.RegexPattern) - } else { - fmt.Printf("[DEBUG] RunMatch 跳过清洗(正则为空)\n") + prep, err := a.prepareMatch(config) + if err != nil { + return nil, err } + return a.runMatchOnData(prep, config) +} - // 2. 默认值兜底 - timeWindow := config.TimeWindow - if timeWindow <= 0 { - timeWindow = DefaultTimeWindowHours - } - threshold := config.Threshold - if threshold <= 0 { - threshold = DefaultThreshold - } +// runMatchOnData 在已读取的数据上执行匹配 +func (a *App) runMatchOnData(prep *matchPrep, config MatchConfig) ([]MatchResult, error) { useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0 - - // 3. 读取原始数据 - a.emitProgress(0, 100, "正在读取 A 表...", "reading") - rowsA, err := a.readRawRows(config.FileAPath) - if err != nil { - return nil, fmt.Errorf("读取 A 表失败: %v", err) - } - a.emitProgress(0, 100, "正在读取 B 表...", "reading") - rowsB, err := a.readRawRows(config.FileBPath) - if err != nil { - return nil, fmt.Errorf("读取 B 表失败: %v", err) - } - if len(rowsA) < 2 { - return nil, fmt.Errorf("A 表无有效数据行") - } - if len(rowsB) < 2 { - return nil, fmt.Errorf("B 表无有效数据行") - } - - aHeaders := rowsA[0] - _ = aHeaders // 保留表头引用(将来导出时可能用到) - dataA := rowsA[1:] - dataB := rowsB[1:] - windowDuration := time.Duration(timeWindow * float64(time.Hour)) - totalA := len(dataA) + totalA := len(prep.dataA) var results []MatchResult useAllMatches := config.AllMatches @@ -268,7 +276,32 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { maxPreview = DefaultMaxPreview } - for i, rowA := range dataA { + // 预计算 B 表清洗后的匹配值,避免内层循环中重复 regex 替换 + totalB := len(prep.dataB) + cleanedBMatch := make([]string, totalB) + origBMatch := make([]string, totalB) + parsedBTime := make([]time.Time, totalB) + hasBTime := make([]bool, totalB) + bExtractVal := make([]string, totalB) + for bIdx, rowB := range prep.dataB { + matchStrB := getCell(rowB, config.ColBMatchIndex) + origBMatch[bIdx] = matchStrB + if matchStrB == "" { + cleanedBMatch[bIdx] = "" + } else { + cleanedBMatch[bIdx] = cleanWithRegex(matchStrB, prep.reg) + } + if useTime { + t, err := parseTimeFlexible(getCell(rowB, config.ColBTimeIndex)) + if err == nil { + parsedBTime[bIdx] = t + hasBTime[bIdx] = true + } + } + bExtractVal[bIdx] = getCell(rowB, config.ColBExtractIndex) + } + + for i, rowA := range prep.dataA { if i%10 == 0 || i == totalA-1 { pct := (i + 1) * 100 / totalA a.emitProgress(i+1, totalA, @@ -287,38 +320,35 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { if err == nil { timeA = t; hasTimeA = true } } - cleanA := cleanWithRegex(matchStrA, reg) + cleanA := cleanWithRegex(matchStrA, prep.reg) + // 收集该 A 行的所有候选匹配 var candidates []MatchResult - for _, rowB := range dataB { - matchStrB := getCell(rowB, config.ColBMatchIndex) - if matchStrB == "" { continue } + for bIdx := range prep.dataB { + if cleanedBMatch[bIdx] == "" { continue } - var timeDiff time.Duration - if hasTimeA && useTime { - tB, err := parseTimeFlexible(getCell(rowB, config.ColBTimeIndex)) - if err != nil { continue } - td := timeA.Sub(tB) - if td < -windowDuration || td > windowDuration { continue } - timeDiff = td + if hasTimeA && useTime && hasBTime[bIdx] { + td := timeA.Sub(parsedBTime[bIdx]) + if td < -prep.windowDuration || td > prep.windowDuration { continue } } - cleanB := cleanWithRegex(matchStrB, reg) - if cleanA == "" || cleanB == "" { continue } - - similarity := calcSimilarity(matchStrA, matchStrB, reg, config.CaseSensitive) + similarity := similarityFromCleaned(cleanA, cleanedBMatch[bIdx], config.CaseSensitive) if i < maxPreview { fmt.Printf("[DEBUG] | A[%d]='%s'→'%s' | B='%s'→'%s' | 相似度=%.4f\n", - i, matchStrA, cleanA, matchStrB, cleanB, similarity) + i, matchStrA, cleanA, origBMatch[bIdx], cleanedBMatch[bIdx], similarity) } - if similarity >= threshold { + if similarity >= prep.threshold { + var timeDiff time.Duration + if hasTimeA && useTime && hasBTime[bIdx] { + timeDiff = timeA.Sub(parsedBTime[bIdx]) + } mr := MatchResult{ RowAData: rowA, - RowBKey: matchStrB, - ExtractValue: getCell(rowB, config.ColBExtractIndex), + RowBKey: origBMatch[bIdx], + ExtractValue: bExtractVal[bIdx], TimeDiff: formatTimeDiff(timeDiff), SimilarityScore: math.Round(similarity*10000) / 10000, AIMatched: false, @@ -343,13 +373,14 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { } // 结果排序 - if config.SortBy == "similarity" { + switch config.SortBy { + case "similarity": sort.Slice(results, func(i, j int) bool { return results[i].SimilarityScore > results[j].SimilarityScore }) - } else if config.SortBy == "timeDiff" { + case "timeDiff": sort.Slice(results, func(i, j int) bool { - return results[i].TimeDiff < results[j].TimeDiff + return parseTimeDiffDuration(results[i].TimeDiff) < parseTimeDiffDuration(results[j].TimeDiff) }) } @@ -359,42 +390,31 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) { return results, nil } -// RunMatchWithAI 执行基础匹配 + Deepseek AI 增强匹配(配置驱动) +// RunMatchWithAI 执行基础匹配 + AI 增强匹配(配置驱动) func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { - if a.deepseekKey == "" { - return nil, fmt.Errorf("请先设置 Deepseek API 密钥") + if a.apiKey == "" { + return nil, fmt.Errorf("请先设置 AI API 密钥") } - // 1. 先执行基础匹配 - results, err := a.RunMatch(config) + prep, err := a.prepareMatch(config) if err != nil { return nil, err } - // 2. 重新读取数据,找出未被基础匹配覆盖的 A 表行 - rowsA, err := a.readRawRows(config.FileAPath) + // 1. 先执行基础匹配 + results, err := a.runMatchOnData(prep, config) if err != nil { - return nil, fmt.Errorf("读取 A 表失败: %v", err) - } - rowsB, err := a.readRawRows(config.FileBPath) - if err != nil { - return nil, fmt.Errorf("读取 B 表失败: %v", err) - } - if len(rowsA) < 2 || len(rowsB) < 2 { - return results, nil + return nil, err } - dataA := rowsA[1:] - dataB := rowsB[1:] - - // 用 RowAData 快速判断哪些 A 行已经被匹配 + // 2. 找出未被基础匹配覆盖的 A 表行 matchedSet := make(map[string]bool) for _, r := range results { matchedSet[strings.Join(r.RowAData, "\x00")] = true } var unmatchedA [][]string - for _, row := range dataA { + for _, row := range prep.dataA { if !matchedSet[strings.Join(row, "\x00")] { unmatchedA = append(unmatchedA, row) } @@ -405,33 +425,61 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { return results, nil } - // 3. AI 增强匹配 - timeWindow := config.TimeWindow - if timeWindow <= 0 { - timeWindow = DefaultTimeWindowHours - } - windowDuration := time.Duration(timeWindow * float64(time.Hour)) + // 3. AI 增强匹配(先查行级缓存,减少 API 调用) useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0 - totalUnmatched := len(unmatchedA) + aiMatched := 0 + var failedBatches []int + + // 3a. 检查行级缓存,命中则直接加入结果 + var uncachedA [][]string + cacheHits := 0 + for _, row := range unmatchedA { + matchVal := getCell(row, config.ColAMatchIndex) + timeStr := "" + if useTime { + timeStr = getCell(row, config.ColATimeIndex) + } + cacheKey := a.buildRowCacheKey(matchVal, timeStr, config) + if cachedVal, ok := a.aiCache.getRow(cacheKey); ok { + results = append(results, MatchResult{ + RowAData: row, + RowBKey: "", + ExtractValue: cachedVal, + SimilarityScore: 0, + AIMatched: true, + }) + aiMatched++ + cacheHits++ + } else { + uncachedA = append(uncachedA, row) + } + } + + if cacheHits > 0 { + fmt.Printf("[CACHE] ✓ 行级缓存命中 %d 条,剩余 %d 条需 AI 处理\n", cacheHits, len(uncachedA)) + } + + if len(uncachedA) == 0 { + a.emitProgress(1, 1, + fmt.Sprintf("AI 增强完成!全部 %d 条命中缓存", cacheHits), "done") + return results, nil + } + + totalUnmatched := len(uncachedA) a.emitProgress(0, totalUnmatched, - fmt.Sprintf("AI 增强匹配:还有 %d 条未匹配记录,正在调用 Deepseek...", totalUnmatched), + fmt.Sprintf("AI 增强匹配:%d 条命中缓存,%d 条需调用 AI...", cacheHits, totalUnmatched), "ai-enhancing") - batchSize := DefaultBatchSize - aiMatched := 0 - - for batchStart := 0; batchStart < totalUnmatched; batchStart += batchSize { - end := batchStart + batchSize - if end > totalUnmatched { - end = totalUnmatched - } + for batchStart := 0; batchStart < totalUnmatched; batchStart += DefaultBatchSize { + end := min(batchStart+DefaultBatchSize, totalUnmatched) + batchNum := (batchStart / DefaultBatchSize) + 1 a.emitProgress(batchStart+1, totalUnmatched, - fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, (batchStart/batchSize)+1), + fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, batchNum), "ai-enhancing") - batch := unmatchedA[batchStart:end] + batch := uncachedA[batchStart:end] // 计算本批 A 表的时间范围 var minTime, maxTime time.Time @@ -459,10 +507,10 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { // 过滤 B 表在时间窗口内的行(用户配置时间窗口 + 额外余量覆盖批次跨度) var relevantB [][]string if hasBatchTime && useTime { - padding := windowDuration + 3*time.Hour + padding := prep.windowDuration + time.Duration(defaultAIWindowPadH)*time.Hour ws := minTime.Add(-padding) we := maxTime.Add(padding) - for _, row := range dataB { + for _, row := range prep.dataB { t, err := parseTimeFlexible(getCell(row, config.ColBTimeIndex)) if err != nil || t.Before(ws) || t.After(we) { continue @@ -471,17 +519,16 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { } } else { // 无时间列时限制 B 表条数以控制 token 消耗 - maxB := 200 - if len(dataB) < maxB { - maxB = len(dataB) - } - relevantB = dataB[:maxB] + maxB := min(defaultMaxBNoTime, len(prep.dataB)) + relevantB = prep.dataB[:maxB] } // 构建 AI 提示 - prompt := a.buildGenericAIPrompt(batch, relevantB, config, windowDuration, hasBatchTime) - aiResp, err := a.callDeepseekAPI(prompt) + prompt := a.buildGenericAIPrompt(batch, relevantB, config, prep.windowDuration, hasBatchTime) + aiResp, err := a.callAIAPI(prompt) if err != nil { + fmt.Printf("[AI-WARN] 第 %d 批 API 调用失败: %v\n", batchNum, err) + failedBatches = append(failedBatches, batchNum) continue } @@ -502,7 +549,8 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { } if parseErr != nil { fmt.Printf("[AI-WARN] 响应解析失败 (第 %d 批): %s\n 原始响应: %.200s\n", - (batchStart/batchSize)+1, parseErr.Error(), aiResp) + batchNum, parseErr.Error(), aiResp) + failedBatches = append(failedBatches, batchNum) continue } @@ -522,12 +570,26 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { } results = append(results, mr) aiMatched++ + + // 写入行级缓存 + matchVal := getCell(rowA, config.ColAMatchIndex) + timeStr := "" + if useTime { + timeStr = getCell(rowA, config.ColATimeIndex) + } + cacheKey := a.buildRowCacheKey(matchVal, timeStr, config) + a.aiCache.putRow(cacheKey, val) } } + a.aiCache.saveToFile() - a.emitProgress(totalUnmatched, totalUnmatched, - fmt.Sprintf("AI 增强完成!基础匹配 %d 条 + AI 补充 %d 条 = 共 %d 条", - len(results)-aiMatched, aiMatched, len(results)), "done") + // 构建完成消息 + msg := fmt.Sprintf("AI 增强完成!基础匹配 %d 条 + AI 补充 %d 条 = 共 %d 条", + len(results)-aiMatched, aiMatched, len(results)) + if len(failedBatches) > 0 { + msg += fmt.Sprintf("(警告:第 %v 批失败)", failedBatches) + } + a.emitProgress(totalUnmatched, totalUnmatched, msg, "done") return results, nil } diff --git a/cache.go b/cache.go index b1b1dde..cd399b5 100644 --- a/cache.go +++ b/cache.go @@ -2,8 +2,10 @@ package main import ( "encoding/json" + "fmt" "os" "path/filepath" + "sort" "sync" "time" ) @@ -17,12 +19,23 @@ type AICacheEntry struct { CreatedAt int64 `json:"createdAt"` } +// AIRowCacheEntry 单行 AI 匹配缓存(跨批次复用) +type AIRowCacheEntry struct { + Key string `json:"key"` + Value string `json:"value"` + CreatedAt int64 `json:"createdAt"` +} + // AICache AI 响应缓存(持久化到临时文件) type AICache struct { - Entries []AICacheEntry `json:"entries"` - mu sync.RWMutex // 小写,必须保持非导出以兼容 JSON 序列化 - filePath string - maxSize int // 最大缓存条目数 + Entries []AICacheEntry `json:"entries"` + RowEntries []AIRowCacheEntry `json:"rowEntries"` + entriesIdx map[string]int // promptHash → index in Entries (O(1) lookup) + rowEntriesIdx map[string]int // key → index in RowEntries (O(1) lookup) + mu sync.RWMutex + filePath string + maxSize int // 批量缓存最大条目数 + maxRowSize int // 行级缓存最大条目数 } // cacheFileName 缓存文件名 @@ -31,8 +44,9 @@ const cacheFileName = "data-matcher-ai-cache.json" // newAICache 创建缓存实例并加载已有数据 func newAICache() *AICache { c := &AICache{ - filePath: filepath.Join(os.TempDir(), cacheFileName), - maxSize: cacheMaxSize, + filePath: filepath.Join(os.TempDir(), cacheFileName), + maxSize: cacheMaxSize, + maxRowSize: 5000, } c.loadFromFile() return c @@ -42,12 +56,18 @@ func newAICache() *AICache { func (c *AICache) loadFromFile() { data, err := os.ReadFile(c.filePath) if err != nil { - return // 文件不存在或无法读取,从空缓存开始 + if !os.IsNotExist(err) { + fmt.Printf("[CACHE] 读取缓存文件失败: %v\n", err) + } + return } c.mu.Lock() defer c.mu.Unlock() - var loaded AICache - if err := json.Unmarshal(data, &loaded); err != nil || len(loaded.Entries) == 0 { + var loaded struct { + Entries []AICacheEntry `json:"entries"` + RowEntries []AIRowCacheEntry `json:"rowEntries"` + } + if err := json.Unmarshal(data, &loaded); err != nil || (len(loaded.Entries) == 0 && len(loaded.RowEntries) == 0) { return // 解析失败或无数据,保留当前缓存 } // 验证每个条目字段完整性 @@ -56,32 +76,56 @@ func (c *AICache) loadFromFile() { return } } + for _, r := range loaded.RowEntries { + if r.Key == "" { + return + } + } c.Entries = loaded.Entries + c.RowEntries = loaded.RowEntries + c.rebuildIndexes() +} + +// rebuildIndexes 从切片重建索引 map(反序列化或裁剪后调用) +func (c *AICache) rebuildIndexes() { + c.entriesIdx = make(map[string]int, len(c.Entries)) + for i := range c.Entries { + c.entriesIdx[c.Entries[i].PromptHash] = i + } + c.rowEntriesIdx = make(map[string]int, len(c.RowEntries)) + for i := range c.RowEntries { + c.rowEntriesIdx[c.RowEntries[i].Key] = i + } } // saveToFile 将缓存写入磁盘(线程安全) func (c *AICache) saveToFile() { c.mu.RLock() - // 仅序列化 Entries,避免泄露 filePath 等内部字段 entries := make([]AICacheEntry, len(c.Entries)) copy(entries, c.Entries) + rowEntries := make([]AIRowCacheEntry, len(c.RowEntries)) + copy(rowEntries, c.RowEntries) c.mu.RUnlock() - data, err := json.Marshal(entries) + data, err := json.Marshal(map[string]interface{}{ + "entries": entries, + "rowEntries": rowEntries, + }) if err != nil { + fmt.Printf("[CACHE] 序列化缓存失败: %v\n", err) return } - _ = os.WriteFile(c.filePath, data, 0644) + if err := os.WriteFile(c.filePath, data, 0600); err != nil { + fmt.Printf("[CACHE] 写入缓存文件失败: %v\n", err) + } } // get 根据 hash 查找缓存,命中返回响应,否则返回空 func (c *AICache) get(hash string) (string, bool) { c.mu.RLock() defer c.mu.RUnlock() - for i := range c.Entries { - if c.Entries[i].PromptHash == hash { - return c.Entries[i].Response, true - } + if idx, ok := c.entriesIdx[hash]; ok && idx < len(c.Entries) && c.Entries[idx].PromptHash == hash { + return c.Entries[idx].Response, true } return "", false } @@ -92,32 +136,70 @@ func (c *AICache) put(hash, response string) { defer c.mu.Unlock() // 去重:如果已存在则覆盖 - for i := range c.Entries { - if c.Entries[i].PromptHash == hash { - c.Entries[i].Response = response - c.Entries[i].CreatedAt = time.Now().Unix() - return - } - } - - // 超过上限则移除最旧条目,避免全部排序 - if len(c.Entries) >= c.maxSize { - oldestIdx := 0 - oldestTime := c.Entries[0].CreatedAt - for i := 1; i < len(c.Entries); i++ { - if c.Entries[i].CreatedAt < oldestTime { - oldestIdx = i - oldestTime = c.Entries[i].CreatedAt - } - } - c.Entries = append(c.Entries[:oldestIdx], c.Entries[oldestIdx+1:]...) + if idx, ok := c.entriesIdx[hash]; ok && idx < len(c.Entries) && c.Entries[idx].PromptHash == hash { + c.Entries[idx].Response = response + c.Entries[idx].CreatedAt = time.Now().Unix() + return } + // 新增条目 + idx := len(c.Entries) c.Entries = append(c.Entries, AICacheEntry{ PromptHash: hash, Response: response, CreatedAt: time.Now().Unix(), }) + c.entriesIdx[hash] = idx + + // 超过上限则删除最旧的条目 + if len(c.Entries) > c.maxSize { + sort.Slice(c.Entries, func(i, j int) bool { + return c.Entries[i].CreatedAt > c.Entries[j].CreatedAt + }) + c.Entries = c.Entries[:c.maxSize] + c.rebuildIndexes() + } +} + +// getRow 查找行级缓存 +func (c *AICache) getRow(key string) (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if idx, ok := c.rowEntriesIdx[key]; ok && idx < len(c.RowEntries) && c.RowEntries[idx].Key == key { + return c.RowEntries[idx].Value, true + } + return "", false +} + +// putRow 存入行级缓存(线程安全 + 自动裁剪) +func (c *AICache) putRow(key, value string) { + c.mu.Lock() + defer c.mu.Unlock() + + // 去重:更新已存在的条目 + if idx, ok := c.rowEntriesIdx[key]; ok && idx < len(c.RowEntries) && c.RowEntries[idx].Key == key { + c.RowEntries[idx].Value = value + c.RowEntries[idx].CreatedAt = time.Now().Unix() + return + } + + // 新增条目 + idx := len(c.RowEntries) + c.RowEntries = append(c.RowEntries, AIRowCacheEntry{ + Key: key, + Value: value, + CreatedAt: time.Now().Unix(), + }) + c.rowEntriesIdx[key] = idx + + // 超过上限则删除最旧的条目 + if len(c.RowEntries) > c.maxRowSize { + sort.Slice(c.RowEntries, func(i, j int) bool { + return c.RowEntries[i].CreatedAt > c.RowEntries[j].CreatedAt + }) + c.RowEntries = c.RowEntries[:c.maxRowSize] + c.rebuildIndexes() + } } // clear 清空所有缓存 @@ -125,6 +207,9 @@ func (c *AICache) clear() { c.mu.Lock() defer c.mu.Unlock() c.Entries = nil + c.RowEntries = nil + c.entriesIdx = nil + c.rowEntriesIdx = nil _ = os.Remove(c.filePath) } @@ -132,5 +217,5 @@ func (c *AICache) clear() { func (c *AICache) stat() (count int, path string) { c.mu.RLock() defer c.mu.RUnlock() - return len(c.Entries), c.filePath + return len(c.Entries) + len(c.RowEntries), c.filePath } diff --git a/export.go b/export.go index f82840c..0dba346 100644 --- a/export.go +++ b/export.go @@ -1,7 +1,9 @@ package main import ( + "bytes" "fmt" + "os" "strings" "time" @@ -11,17 +13,32 @@ import ( // ---------- 导出结果 ---------- -// ExportResults 将匹配结果导出为 Excel 文件 +// ExportResults 将匹配结果导出为 Excel 或 CSV 文件 func (a *App) ExportResults(results []MatchResult) (string, error) { if len(results) == 0 { return "", fmt.Errorf("没有匹配结果可以导出") } + a.dataMu.RLock() + useCSV := a.lastConfig.ExportFormat == "csv" + includeHdr := a.lastConfig.IncludeHeader + a.dataMu.RUnlock() + + isCSV := useCSV + ext := ".xlsx" + filterDisplay := "Excel 文件 (*.xlsx)" + filterPattern := "*.xlsx" + if isCSV { + ext = ".csv" + filterDisplay = "CSV 文件 (*.csv)" + filterPattern = "*.csv" + } + savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "导出匹配结果", - DefaultFilename: fmt.Sprintf("匹配结果_%s.xlsx", time.Now().Format("20060102_150405")), + DefaultFilename: fmt.Sprintf("匹配结果_%s%s", time.Now().Format("20060102_150405"), ext), Filters: []runtime.FileFilter{ - {DisplayName: "Excel 文件 (*.xlsx)", Pattern: "*.xlsx"}, + {DisplayName: filterDisplay, Pattern: filterPattern}, }, }) if err != nil { @@ -30,78 +47,109 @@ func (a *App) ExportResults(results []MatchResult) (string, error) { if savePath == "" { return "", nil } - if !strings.HasSuffix(strings.ToLower(savePath), ".xlsx") { - savePath += ".xlsx" + if !strings.HasSuffix(strings.ToLower(savePath), ext) { + savePath += ext } + if isCSV { + return a.exportResultsCSV(results, savePath, includeHdr) + } + return a.exportResultsXLSX(results, savePath, includeHdr) +} + +// exportHeaders 构建导出表头行(使用真实表头或回退默认) +func (a *App) exportHeaders(numACols int) []string { + a.dataMu.RLock() + hdrA := make([]string, len(a.headersA)) + copy(hdrA, a.headersA) + a.dataMu.RUnlock() + + headers := make([]string, 0, numACols+1) + if len(hdrA) >= numACols { + for _, h := range hdrA[:numACols] { + n := h + if n == "" { + n = fmt.Sprintf("Col%d", len(headers)+1) + } + headers = append(headers, n) + } + } else { + for i := 0; i < numACols; i++ { + headers = append(headers, fmt.Sprintf("A-Col%d", i+1)) + } + } + headers = append(headers, "匹配结果(由B表提取)") + return headers +} + +func (a *App) exportResultsXLSX(results []MatchResult, savePath string, includeHeader bool) (string, error) { f := excelize.NewFile() defer f.Close() sheetName := "匹配结果" f.SetSheetName("Sheet1", sheetName) - // 判断使用新格式还是旧格式 - if len(results) > 0 && len(results[0].RowAData) > 0 { - // 新格式:A 表所有原始列 + 最后追加「匹配结果(由B表提取)」 - numACols := len(results[0].RowAData) - colLetter := func(n int) string { c, _ := excelize.ColumnNumberToName(n + 1); return c } - colNums := make([]int, numACols+1) - for i := 0; i < numACols; i++ { - colNums[i] = i - f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(i)), fmt.Sprintf("A-Col%d", i+1)) - } - extractCol := numACols - colNums[numACols] = extractCol - f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(extractCol)), "匹配结果(由B表提取)") + numACols := len(results[0].RowAData) + colLetter := func(n int) string { c, _ := excelize.ColumnNumberToName(n + 1); return c } + headers := a.exportHeaders(numACols) + extractCol := numACols + + // 表头 + if includeHeader { + for i, h := range headers { + f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(i)), h) + } headerStyle, _ := f.NewStyle(&excelize.Style{ Font: &excelize.Font{Bold: true, Size: 12, Color: "FFFFFF"}, Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, }) f.SetCellStyle(sheetName, "A1", fmt.Sprintf("%s1", colLetter(extractCol)), headerStyle) - - for i, r := range results { - rowNum := i + 2 - for _, ci := range colNums { - if ci < numACols { - f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), r.RowAData[ci]) - } else { - f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), r.ExtractValue) - } - } - } - for ci := range colNums { - f.SetColWidth(sheetName, colLetter(ci), colLetter(ci), 22) + // 数据行样式(带边框和行号字体) + dataStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Size: 11}, + Border: []excelize.Border{ + {Type: "bottom", Color: "D9D9D9", Style: 1}, + }, + }) + firstDataRow := 2 + lastDataRow := len(results) + 1 + for ci := 0; ci <= numACols; ci++ { + f.SetCellStyle(sheetName, + fmt.Sprintf("%s%d", colLetter(ci), firstDataRow), + fmt.Sprintf("%s%d", colLetter(ci), lastDataRow), + dataStyle) } } else { - // 旧格式 向后兼容 - headers := []string{"月报小区名称", "日报小区号", "匹配时间差", "相似度得分", "统计到的中断原因", "AI辅助匹配"} - for i, h := range headers { - col, _ := excelize.ColumnNumberToName(i + 1) - f.SetCellValue(sheetName, fmt.Sprintf("%s1", col), h) - } - headerStyle, _ := f.NewStyle(&excelize.Style{ - Font: &excelize.Font{Bold: true, Size: 12, Color: "FFFFFF"}, - Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}}, + dataStyle, _ := f.NewStyle(&excelize.Style{ + Font: &excelize.Font{Size: 11}, + Border: []excelize.Border{ + {Type: "bottom", Color: "D9D9D9", Style: 1}, + }, }) - lastCol, _ := excelize.ColumnNumberToName(len(headers)) - f.SetCellStyle(sheetName, "A1", fmt.Sprintf("%s1", lastCol), headerStyle) + lastDataRow := len(results) + for ci := 0; ci <= numACols; ci++ { + f.SetCellStyle(sheetName, + fmt.Sprintf("%s%d", colLetter(ci), 1), + fmt.Sprintf("%s%d", colLetter(ci), lastDataRow), + dataStyle) + } + } - for i, r := range results { - rowNum := i + 2 - f.SetCellValue(sheetName, fmt.Sprintf("A%d", rowNum), r.MonthlyCellName) - f.SetCellValue(sheetName, fmt.Sprintf("B%d", rowNum), r.DailyCellID) - f.SetCellValue(sheetName, fmt.Sprintf("C%d", rowNum), r.TimeDiff) - f.SetCellValue(sheetName, fmt.Sprintf("D%d", rowNum), r.SimilarityScore) - f.SetCellValue(sheetName, fmt.Sprintf("E%d", rowNum), r.InterruptReason) - aiLabel := "否" - if r.AIMatched { - aiLabel = "是" - } - f.SetCellValue(sheetName, fmt.Sprintf("F%d", rowNum), aiLabel) + // 数据行 + for i, r := range results { + rowNum := i + 2 + if !includeHeader { + rowNum = i + 1 } - for _, c := range []string{"A", "B", "C", "D", "E", "F"} { - f.SetColWidth(sheetName, c, c, 22) + for ci := 0; ci < numACols; ci++ { + f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), r.RowAData[ci]) } + f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(extractCol), rowNum), r.ExtractValue) + } + + // 列宽 + for ci := 0; ci <= numACols; ci++ { + f.SetColWidth(sheetName, colLetter(ci), colLetter(ci), 22) } if err := f.SaveAs(savePath); err != nil { @@ -109,3 +157,49 @@ func (a *App) ExportResults(results []MatchResult) (string, error) { } return savePath, nil } + +func (a *App) exportResultsCSV(results []MatchResult, savePath string, includeHeader bool) (string, error) { + var buf bytes.Buffer + // 使用 UTF-8 BOM 帮助 Excel 正确识别编码 + buf.Write([]byte{0xEF, 0xBB, 0xBF}) + + numACols := len(results[0].RowAData) + headers := a.exportHeaders(numACols) + + // 表头行 + if includeHeader { + for i, h := range headers { + if i > 0 { + buf.WriteByte(',') + } + buf.WriteString(csvEscape(h)) + } + buf.WriteByte('\n') + } + + // 数据行 + for _, r := range results { + for ci := 0; ci < numACols; ci++ { + if ci > 0 { + buf.WriteByte(',') + } + buf.WriteString(csvEscape(r.RowAData[ci])) + } + buf.WriteByte(',') + buf.WriteString(csvEscape(r.ExtractValue)) + buf.WriteByte('\n') + } + + if err := os.WriteFile(savePath, buf.Bytes(), 0600); err != nil { + return "", fmt.Errorf("保存 CSV 文件失败: %v", err) + } + return savePath, nil +} + +// csvEscape 对 CSV 字段进行转义(含逗号或引号时包裹双引号) +func csvEscape(s string) string { + if strings.ContainsAny(s, "\",\n\r") { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + return s +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1b9d4b7..b022b02 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' // Wails 自动生成的绑定 -import { OpenFileA, OpenFileB, ParseHeaders, RunMatch, RunMatchWithAI, ExportResults, SetDeepseekAPIKey, GetDeepseekStatus, ClearAICache, GetAICacheInfo } from '../wailsjs/go/main/App' +import { OpenFileA, OpenFileB, ParseHeaders, RunMatch, RunMatchWithAI, ExportResults, SetAIConfig, SetAPIKey, GetAIStatus, ClearAICache, GetAICacheInfo } from '../wailsjs/go/main/App' import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime' // ----------- 文件与列映射 ----------- @@ -67,9 +67,11 @@ function hideProgressNow() { showProgress.value = false } -// ----------- Deepseek 状态 ----------- -const deepseekKey = ref('') -const deepseekReady = ref(false) +// ----------- AI API 配置 ----------- +const apiKey = ref('') +const apiEndpoint = ref('') +const apiModel = ref('') +const aiReady = ref(false) const showApiInput = ref(false) const aiEnhancing = ref(false) @@ -114,6 +116,25 @@ async function selectFileB() { } catch (e) { errorMsg.value = '读取 B 表头失败: ' + (e.message || e) } } +// ----------- 构建匹配配置 ----------- +function buildMatchConfig() { + return { + fileAPath: fileAPath.value, fileBPath: fileBPath.value, + colAMatchIndex: colAMatchIdx.value, colATimeIndex: colATimeIdx.value, + colBMatchIndex: colBMatchIdx.value, colBTimeIndex: colBTimeIdx.value, + colBExtractIndex: colBExtractIdx.value, + regexPattern: matchConfig.value.regexPattern || '', + timeWindow: Number(matchConfig.value.timeWindow) || 12, + threshold: Number(matchConfig.value.threshold) || 0.65, + allMatches: matchConfig.value.allMatches || false, + caseSensitive: matchConfig.value.caseSensitive || false, + sortBy: matchConfig.value.sortBy || '', + maxPreview: Number(matchConfig.value.maxPreview) || 0, + exportFormat: matchConfig.value.exportFormat || 'xlsx', + includeHeader: matchConfig.value.includeHeader !== false + } +} + // ----------- 智能匹配 ----------- async function startMatching() { if (isRunning.value) return @@ -129,22 +150,7 @@ async function startMatching() { progress.value = { current: 0, total: 100, message: '准备中...', phase: 'reading' } try { - const config = { - fileAPath: fileAPath.value, fileBPath: fileBPath.value, - colAMatchIndex: colAMatchIdx.value, colATimeIndex: colATimeIdx.value, - colBMatchIndex: colBMatchIdx.value, colBTimeIndex: colBTimeIdx.value, - colBExtractIndex: colBExtractIdx.value, - regexPattern: matchConfig.value.regexPattern || '', - timeWindow: Number(matchConfig.value.timeWindow) || 12, - threshold: Number(matchConfig.value.threshold) || 0.65, - allMatches: matchConfig.value.allMatches || false, - caseSensitive: matchConfig.value.caseSensitive || false, - sortBy: matchConfig.value.sortBy || '', - maxPreview: Number(matchConfig.value.maxPreview) || 0, - exportFormat: matchConfig.value.exportFormat || 'xlsx', - includeHeader: matchConfig.value.includeHeader !== false - } - const data = await RunMatch(config) + const data = await RunMatch(buildMatchConfig()) results.value = data; stats.value.matched = data.length } catch (err) { errorMsg.value = typeof err === 'string' ? err : (err.message || '匹配失败') hideProgressNow() @@ -161,16 +167,16 @@ async function startAIEnhance() { errorMsg.value = '请完成列映射配置(A表匹配列 / B表匹配列 / B表提取列)' return } - if (!deepseekReady.value && !deepseekKey.value) { - errorMsg.value = '请先配置 Deepseek API 密钥' + if (!aiReady.value && !apiKey.value) { + errorMsg.value = '请先配置 AI API 密钥' return } cancelProgressTimer() - if (!deepseekReady.value && deepseekKey.value) { - await SetDeepseekAPIKey(deepseekKey.value) - deepseekReady.value = await GetDeepseekStatus() + if (!aiReady.value && apiKey.value) { + await SetAIConfig(apiEndpoint.value, apiModel.value, apiKey.value) + aiReady.value = true } isRunning.value = true @@ -183,22 +189,7 @@ async function startAIEnhance() { progress.value = { current: 0, total: 100, message: '正在启动 AI 增强匹配...', phase: 'reading' } try { - const config = { - fileAPath: fileAPath.value, fileBPath: fileBPath.value, - colAMatchIndex: colAMatchIdx.value, colATimeIndex: colATimeIdx.value, - colBMatchIndex: colBMatchIdx.value, colBTimeIndex: colBTimeIdx.value, - colBExtractIndex: colBExtractIdx.value, - regexPattern: matchConfig.value.regexPattern || '', - timeWindow: Number(matchConfig.value.timeWindow) || 12, - threshold: Number(matchConfig.value.threshold) || 0.65, - allMatches: matchConfig.value.allMatches || false, - caseSensitive: matchConfig.value.caseSensitive || false, - sortBy: matchConfig.value.sortBy || '', - maxPreview: Number(matchConfig.value.maxPreview) || 0, - exportFormat: matchConfig.value.exportFormat || 'xlsx', - includeHeader: matchConfig.value.includeHeader !== false - } - const data = await RunMatchWithAI(config) + const data = await RunMatchWithAI(buildMatchConfig()) results.value = data stats.value.matched = data.length } catch (err) { @@ -227,12 +218,13 @@ async function exportResult() { } } -// ----------- Deepseek 密钥管理 ----------- -async function saveApiKey() { - if (!deepseekKey.value) return - const result = await SetDeepseekAPIKey(deepseekKey.value) - deepseekReady.value = await GetDeepseekStatus() - if (deepseekReady.value) { +// ----------- AI API 密钥管理 ----------- +async function saveApiConfig() { + if (!apiKey.value) return + await SetAIConfig(apiEndpoint.value, apiModel.value, apiKey.value) + const status = await GetAIStatus() + aiReady.value = status.ready === 'true' + if (aiReady.value) { setTimeout(() => { showApiInput.value = false }, 1000) } } @@ -240,11 +232,15 @@ async function saveApiKey() { // ----------- 进度监听 ----------- onMounted(async () => { - // 检查 Deepseek 状态 - deepseekReady.value = await GetDeepseekStatus() + // 恢复 AI API 配置 + try { + const status = await GetAIStatus() + aiReady.value = status.ready === 'true' + apiEndpoint.value = status.endpoint || '' + apiModel.value = status.model || '' + } catch { /* ignore */ } // 检查 AI 缓存状态 await refreshCacheInfo() - // 监听匹配进度事件 EventsOn('match-progress', (data) => { progress.value = { current: data.current, @@ -348,7 +344,7 @@ const basicMatchedCount = computed(() => results.value.length - aiMatchedCount.v