275 lines
7.5 KiB
Go
275 lines
7.5 KiB
Go
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
|
||
}
|