package main import ( "crypto/sha1" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" ) func runChatSync() error { logInfo("聊天记录同步 demo 启动") config, err := loadConfig("config.toml") if err != nil { return fmt.Errorf("读取 config.toml 失败:%w", err) } regionsPath := appDataPath(config, config.Agent.RegionsPath) screenshotPath := appDataPath(config, config.Agent.ScreenshotPath) archivePath := appDataPath(config, config.Agent.ChatRecordsPath) annotation, err := loadAnnotation(regionsPath) if err != nil { return fmt.Errorf("读取标注文件失败:%w", err) } summaries, warnings := inferMissingRegionTypes(summarizeRegions(annotation.Regions)) for _, warning := range warnings { logWarning(warning) } if err := validateChatSyncRegions(summaries); err != nil { return err } archive, existed, err := loadChatArchive(archivePath) if err != nil { return err } if !existed || len(archive.Messages) == 0 { logInfo("本地没有聊天记录,先滚动到聊天记录顶部") if err := scrollChatToTop(config, summaries, screenshotPath); err != nil { return err } } else { logInfo(fmt.Sprintf("已读取本地聊天记录 messages=%d,将从当前视图继续向下读取", len(archive.Messages))) } if archive.App == "" { archive.App = "wechat" archive.CreatedAt = time.Now().Format(time.RFC3339) } archive.Source = TaskSource{RegionsPath: regionsPath, ScreenshotPath: screenshotPath} seenNoNewPages := 0 for pageIndex := 0; pageIndex < config.Agent.ChatSyncMaxPages; pageIndex++ { window, err := findWeChatWindow() if err != nil { return err } if err := captureWindow(window, screenshotPath); err != nil { return err } result, err := extractChatPage(config, summaries, screenshotPath) if err != nil { return err } if result.ContactName != "" && archive.Contact == "" { archive.Contact = result.ContactName } newCount := appendChatMessages(&archive, result.Messages, pageIndex) archive.Pages = append(archive.Pages, ChatPage{ PageIndex: pageIndex, Direction: "down", NewCount: newCount, SeenAt: time.Now().Format(time.RFC3339), }) logTask(fmt.Sprintf("聊天页 page=%d 提取 messages=%d new=%d summary=%s", pageIndex+1, len(result.Messages), newCount, result.PageSummary)) if newCount == 0 { seenNoNewPages++ } else { seenNoNewPages = 0 } if seenNoNewPages >= 2 { logInfo("连续页面没有新增聊天记录,停止同步") break } if pageIndex < config.Agent.ChatSyncMaxPages-1 { if err := scrollChat(config, summaries, -absInt(config.Agent.ScrollDeltaY)); err != nil { return err } time.Sleep(800 * time.Millisecond) } } archive.UpdatedAt = time.Now().Format(time.RFC3339) if err := saveChatArchive(archivePath, archive); err != nil { return err } logInfo(fmt.Sprintf("聊天记录同步完成,messages=%d path=%s", len(archive.Messages), archivePath)) return nil } func validateChatSyncRegions(summaries []RegionSummary) error { index := regionByType(summaries) if _, exists := index["chat_content"]; !exists { return fmt.Errorf("missing required region: chat_content") } return nil } func scrollChatToTop(config Config, summaries []RegionSummary, screenshotPath string) error { for index := 0; index < config.Agent.ChatSyncTopScrolls; index++ { if err := scrollChat(config, summaries, absInt(config.Agent.ScrollDeltaY)); err != nil { return err } logTask(fmt.Sprintf("向上滚动到历史顶部 round=%d/%d", index+1, config.Agent.ChatSyncTopScrolls)) time.Sleep(350 * time.Millisecond) } window, err := findWeChatWindow() if err != nil { return err } return captureWindow(window, screenshotPath) } func scrollChat(config Config, summaries []RegionSummary, deltaY int) error { chat, ok := regionByType(summaries)["chat_content"] if !ok { return fmt.Errorf("chat_content region not found") } window, err := findWeChatWindow() if err != nil { return err } pointX := window.X + chat.CenterSource[0] pointY := window.Y + chat.CenterSource[1] logTask(fmt.Sprintf("滚动聊天区域 point=[%.1f, %.1f] delta_y=%d", pointX, pointY, deltaY)) return scrollAt(pointX, pointY, deltaY) } func extractChatPage(config Config, summaries []RegionSummary, screenshotPath string) (ChatExtractResult, error) { imageDataURL, err := loadImageDataURL(screenshotPath) if err != nil { return ChatExtractResult{}, err } prompt, err := buildChatExtractPrompt(summaries) if err != nil { return ChatExtractResult{}, err } logThink("正在用豆包多模态抽取当前聊天区域消息") raw, err := requestLLM(config, prompt, imageDataURL) if err != nil { return ChatExtractResult{}, err } jsonText, err := extractJSON(raw) if err != nil { _ = saveRawResponse(config, raw) return ChatExtractResult{}, err } var result ChatExtractResult if err := json.Unmarshal([]byte(jsonText), &result); err != nil { _ = saveRawResponse(config, raw) return ChatExtractResult{}, err } return result, nil } func buildChatExtractPrompt(summaries []RegionSummary) (string, error) { regionsJSON, err := json.MarshalIndent(summaries, "", " ") if err != nil { return "", err } return `你是微信聊天记录抽取器。 请只识别截图中 chat_content 标注区域里的聊天消息,不要识别联系人列表、输入框或其它区域。 要求: - 按消息在页面中的从上到下顺序输出。 - sender 可填 "me"、"other" 或能识别到的昵称;无法判断填 "unknown"。 - role 可填 "me"、"other"、"system"。 - time 如果画面中没有明确时间则留空字符串。 - content 必须是消息文本,不能编造。 - 只能返回合法 JSON,不要 Markdown,不要解释。 标注区域: ` + string(regionsJSON) + ` 返回结构: { "contact_name": "unknown", "page_summary": "当前页聊天内容摘要", "messages": [ {"sender":"other","role":"other","time":"","content":"消息文本"} ] }`, nil } func loadChatArchive(path string) (ChatArchive, bool, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return ChatArchive{}, false, nil } return ChatArchive{}, false, err } var archive ChatArchive if err := json.Unmarshal(data, &archive); err != nil { return ChatArchive{}, false, err } return archive, true, nil } func saveChatArchive(path string, archive ChatArchive) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } data, err := json.MarshalIndent(archive, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0644) } func appendChatMessages(archive *ChatArchive, messages []ChatMessage, pageIndex int) int { existing := make(map[string]bool, len(archive.Messages)) for _, message := range archive.Messages { existing[message.ID] = true } newCount := 0 for _, message := range messages { message.Content = strings.TrimSpace(message.Content) if message.Content == "" { continue } message.PageIndex = pageIndex message.SeenAt = time.Now().Format(time.RFC3339) message.ID = chatMessageID(message) if existing[message.ID] { continue } existing[message.ID] = true archive.Messages = append(archive.Messages, message) newCount++ } return newCount } func chatMessageID(message ChatMessage) string { key := strings.Join([]string{ strings.TrimSpace(message.Sender), strings.TrimSpace(message.Role), strings.TrimSpace(message.Time), strings.TrimSpace(message.Content), }, "|") sum := sha1.Sum([]byte(key)) return hex.EncodeToString(sum[:]) } func absInt(value int) int { if value < 0 { return -value } return value }