wechat_ai/agent/chat_sync.go

275 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}