fix: 修复 AI 回复无法送达发送者 + 重复消息 + action角色泄露 + OS环境支持
广播逻辑重构: - AI 回复 (stream_start/response/stream_segments/multi_message/stream_end) 改用 broadcastToUser 发送给所有客户端 - 用户消息回显保持 broadcastToUserExcept 排除发送者 消息去重与角色修复: - CacheMessage(user) 移至回复生成后,避免本轮 LLM 调用出现重复用户消息 - action 角色消息在 DB 存储时映射为 assistant,DeepSeek 等模型不支持自定义角色 - stream_end defer 机制确保错误路径也会终止客户端思考指示器 OS 完整环境支持: - host 包重构为 HostBackend 接口 + Direct/WSL/Docker 三种后端 - 新增 os_exec/os_file/os_system 工具供 AI 在完整 Linux 环境中自由操作 其他: - 视觉模型注入 + 图片预处理后清空 Images 避免传给 Chat 模型 - 图片 URL 相对路径→绝对 URL 转换 - DevTools 链路追踪页面 + 重启修复 - 记忆搜索模糊匹配增强 - 后台思考定时调度支持 - 管理后台页面 (模型配置/用户管理等) - docs/api 更新广播机制说明 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -582,16 +582,7 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 检索相关记忆
|
||||
var memories []memory.MemoryEntry
|
||||
if t.memRetriever != nil {
|
||||
memories, err = t.memRetriever.Retrieve(ctx, t.adminUserID, "最近发生了什么 重要的事情 用户偏好 个人信息")
|
||||
if err != nil {
|
||||
log.Printf("[后台思考] 记忆检索失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 获取当前活跃会话的对话历史(优先活跃会话,回退到管理员主会话)
|
||||
// 2. 获取当前活跃会话的对话历史(优先活跃会话,回退到管理员主会话)
|
||||
var convHistory []model.LLMMessage
|
||||
if t.convStore != nil {
|
||||
t.mu.Lock()
|
||||
@@ -608,6 +599,37 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检索相关记忆(精确检索 + 模糊搜索)
|
||||
var memories []memory.MemoryEntry
|
||||
if t.memRetriever != nil {
|
||||
memories, err = t.memRetriever.Retrieve(ctx, t.adminUserID, "最近发生了什么 重要的事情 用户偏好 个人信息")
|
||||
if err != nil {
|
||||
log.Printf("[后台思考] 记忆检索失败: %v", err)
|
||||
}
|
||||
|
||||
// 模糊搜索:从对话历史提取话题,LLM 扩展关键词后语义搜索
|
||||
if t.memClient != nil && len(convHistory) > 0 {
|
||||
fuzzyQuery := lastUserMessage(convHistory)
|
||||
if fuzzyQuery == "" {
|
||||
fuzzyQuery = "最近对话 重要事件 用户状态"
|
||||
}
|
||||
fuzzyResults := t.fuzzyMemorySearch(ctx, t.adminUserID, fuzzyQuery)
|
||||
seen := make(map[string]bool)
|
||||
for _, m := range memories {
|
||||
seen[m.ID] = true
|
||||
}
|
||||
for _, m := range fuzzyResults {
|
||||
if !seen[m.ID] {
|
||||
seen[m.ID] = true
|
||||
memories = append(memories, m)
|
||||
}
|
||||
}
|
||||
if len(fuzzyResults) > 0 {
|
||||
log.Printf("[后台思考] 模糊搜索补充 %d 条记忆", len(fuzzyResults))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 查询 IoT 设备状态(每次都查询,无间隔限制)
|
||||
var deviceSummary string
|
||||
if t.iotClient != nil {
|
||||
@@ -750,10 +772,9 @@ func (t *Thinker) performThink(triggerReason string) {
|
||||
|
||||
log.Printf("[后台思考] 完成 (触发原因=%s, 内容长度=%d, 工具调用=%d次)", triggerReason, len(finalContent), totalToolCalls)
|
||||
|
||||
// 9. 周期性记忆维护(每 10 次思考触发一次)
|
||||
// 注:不再从思考结果中提取记忆——思考内容基于已有记忆生成,
|
||||
// 再次提取会造成"读取→思考→保存→再次读取"的重复循环。
|
||||
// 9. 记忆维护:机械合并(每10次) + LLM整理(每次)
|
||||
t.maybeMaintainMemories(currentCount)
|
||||
t.performMemoryConsolidation(ctx)
|
||||
}
|
||||
|
||||
// buildThinkingSystemPrompt 构建思考用的系统提示词
|
||||
@@ -1196,6 +1217,375 @@ func (t *Thinker) maybeMaintainMemories(thinkCount int) {
|
||||
}
|
||||
}
|
||||
|
||||
// consolidationAction is a parsed memory consolidation instruction from the LLM.
|
||||
type consolidationAction struct {
|
||||
Action string `json:"action"`
|
||||
IDs []string `json:"ids,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Importance int `json:"importance,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Keywords []string `json:"keywords,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// performMemoryConsolidation uses LLM to review and clean up the memory store.
|
||||
// It identifies duplicates, contradictions, outdated info, and low-quality memories,
|
||||
// then executes merge/delete/update actions.
|
||||
func (t *Thinker) performMemoryConsolidation(ctx context.Context) {
|
||||
if t.memClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
allMemories, err := t.memClient.Query(ctx, model.MemoryQuery{
|
||||
UserID: t.adminUserID,
|
||||
Limit: 200,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[记忆整理] 获取记忆失败: %v", err)
|
||||
return
|
||||
}
|
||||
if len(allMemories) < 5 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[记忆整理] LLM 审查 %d 条记忆...", len(allMemories))
|
||||
|
||||
systemPrompt := t.buildConsolidationPrompt(allMemories)
|
||||
messages := []model.LLMMessage{
|
||||
{Role: model.RoleSystem, Content: systemPrompt},
|
||||
{Role: model.RoleUser, Content: "请审查以上记忆库,找出重复、矛盾、过时和低质量的记忆,输出 JSON 整理方案。如果没有需要整理的,输出空数组 []。"},
|
||||
}
|
||||
|
||||
resp, err := t.toolAdapter.Chat(ctx, messages)
|
||||
if err != nil {
|
||||
log.Printf("[记忆整理] LLM 调用失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
actions := parseConsolidationActions(resp.Content)
|
||||
if len(actions) == 0 {
|
||||
log.Printf("[记忆整理] 记忆库状态良好,无需整理")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[记忆整理] LLM 建议 %d 项操作", len(actions))
|
||||
executed := t.executeConsolidationActions(ctx, actions, allMemories)
|
||||
log.Printf("[记忆整理] 完成: 执行了 %d 项操作", executed)
|
||||
}
|
||||
|
||||
// buildConsolidationPrompt formats all memories as a structured list for LLM review.
|
||||
func (t *Thinker) buildConsolidationPrompt(memories []model.MemoryEntry) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("你是记忆库管理助手。审查以下用户记忆,找出问题并输出 JSON 操作清单。\n\n")
|
||||
sb.WriteString("## 需要识别的问题\n")
|
||||
sb.WriteString("1. 重复记忆 — 多条记忆记录了相同信息 → merge 合并为一条\n")
|
||||
sb.WriteString("2. 矛盾记忆 — 两条记忆互相矛盾(如\"喜欢猫\"vs\"讨厌猫\")→ delete 删除过时的、update 修正错误的\n")
|
||||
sb.WriteString("3. 过时记忆 — 信息已被新记忆取代 → delete 或 update\n")
|
||||
sb.WriteString("4. 低质量记忆 — 太模糊、不完整、无实际信息量 → delete\n\n")
|
||||
sb.WriteString("## JSON 操作格式\n")
|
||||
sb.WriteString("```json\n[\n")
|
||||
sb.WriteString(" {\"action\":\"merge\", \"ids\":[\"id1\",\"id2\"], \"content\":\"合并后的内容\", \"category\":\"personal_info\", \"importance\":8, \"reason\":\"两条记录同一件事\"},\n")
|
||||
sb.WriteString(" {\"action\":\"delete\", \"id\":\"id3\", \"reason\":\"完全被 id1 覆盖\"},\n")
|
||||
sb.WriteString(" {\"action\":\"update\", \"id\":\"id4\", \"content\":\"修正后的内容\", \"importance\":7, \"reason\":\"纠正过时信息\"},\n")
|
||||
sb.WriteString(" {\"action\":\"create\", \"content\":\"需要补充的记忆\", \"category\":\"knowledge\", \"importance\":6, \"reason\":\"从已有记忆推断\"}\n")
|
||||
sb.WriteString("]\n```\n\n")
|
||||
sb.WriteString("## 规则\n")
|
||||
sb.WriteString("- 只输出 JSON 数组,可以用 ```json``` 包裹,不要输出其他解释文字\n")
|
||||
sb.WriteString("- 确实有问题才建议操作,不要强行找问题\n")
|
||||
sb.WriteString("- merge 时保留最重要的那条的 ID,合并内容应包含各条的关键信息\n")
|
||||
sb.WriteString("- 不确定时宁可保守(不操作)\n")
|
||||
sb.WriteString("- importance 范围 1-10,数字越大越重要\n")
|
||||
sb.WriteString("- category 可选: personal_info, user_preference, conversation, knowledge, event, task, relationship\n\n")
|
||||
sb.WriteString(fmt.Sprintf("## 当前记忆库 (%d 条)\n\n", len(memories)))
|
||||
|
||||
for i, m := range memories {
|
||||
sb.WriteString(fmt.Sprintf("%d. [%s] **%s** | cat=%s imp=%d pri=%d | src=%s\n",
|
||||
i+1, m.ID[:min(8, len(m.ID))], m.Content,
|
||||
m.Category, m.Importance, m.Priority, m.Source))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseConsolidationActions extracts JSON actions from LLM response text.
|
||||
func parseConsolidationActions(text string) []consolidationAction {
|
||||
// Try to extract from ```json fences first
|
||||
jsonStr := text
|
||||
if idx := strings.Index(text, "```json"); idx >= 0 {
|
||||
start := idx + 7
|
||||
if end := strings.Index(text[start:], "```"); end >= 0 {
|
||||
jsonStr = text[start : start+end]
|
||||
}
|
||||
} else if idx := strings.Index(text, "```"); idx >= 0 {
|
||||
start := idx + 3
|
||||
if end := strings.Index(text[start:], "```"); end >= 0 {
|
||||
jsonStr = text[start : start+end]
|
||||
}
|
||||
}
|
||||
// Find the JSON array
|
||||
arrStart := strings.Index(jsonStr, "[")
|
||||
arrEnd := strings.LastIndex(jsonStr, "]")
|
||||
if arrStart < 0 || arrEnd <= arrStart {
|
||||
return nil
|
||||
}
|
||||
jsonStr = jsonStr[arrStart : arrEnd+1]
|
||||
|
||||
var actions []consolidationAction
|
||||
if err := json.Unmarshal([]byte(jsonStr), &actions); err != nil {
|
||||
log.Printf("[记忆整理] JSON 解析失败: %v", err)
|
||||
return nil
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// executeConsolidationActions runs the parsed consolidation actions against the memory store.
|
||||
func (t *Thinker) executeConsolidationActions(ctx context.Context, actions []consolidationAction, memories []model.MemoryEntry) int {
|
||||
// Index memories by their short ID prefix for lookup
|
||||
memByShortID := make(map[string]*model.MemoryEntry)
|
||||
for i := range memories {
|
||||
short := memories[i].ID[:min(8, len(memories[i].ID))]
|
||||
memByShortID[short] = &memories[i]
|
||||
}
|
||||
memByFullID := make(map[string]*model.MemoryEntry)
|
||||
for i := range memories {
|
||||
memByFullID[memories[i].ID] = &memories[i]
|
||||
}
|
||||
|
||||
executed := 0
|
||||
for _, a := range actions {
|
||||
switch a.Action {
|
||||
case "delete":
|
||||
id := resolveID(a.ID, memByShortID, memByFullID)
|
||||
if id == "" {
|
||||
log.Printf("[记忆整理] 跳过 delete: 找不到记忆 %s", a.ID)
|
||||
continue
|
||||
}
|
||||
if err := t.memClient.Delete(ctx, id); err != nil {
|
||||
log.Printf("[记忆整理] 删除 %s 失败: %v", a.ID, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[记忆整理] 已删除: %s (原因: %s)", a.ID, a.Reason)
|
||||
executed++
|
||||
|
||||
case "merge":
|
||||
if len(a.IDs) < 2 {
|
||||
continue
|
||||
}
|
||||
// Resolve all IDs, use first as the keeper
|
||||
var resolved []string
|
||||
for _, mid := range a.IDs {
|
||||
if rid := resolveID(mid, memByShortID, memByFullID); rid != "" {
|
||||
resolved = append(resolved, rid)
|
||||
}
|
||||
}
|
||||
if len(resolved) < 2 {
|
||||
continue
|
||||
}
|
||||
keeper := resolved[0]
|
||||
// Update the keeper with merged content
|
||||
cat := model.MemoryCategory(a.Category)
|
||||
if cat == "" {
|
||||
if m, ok := memByFullID[keeper]; ok {
|
||||
cat = m.Category
|
||||
}
|
||||
}
|
||||
imp := a.Importance
|
||||
if imp == 0 {
|
||||
if m, ok := memByFullID[keeper]; ok {
|
||||
imp = m.Importance + 1
|
||||
}
|
||||
}
|
||||
if imp > 10 {
|
||||
imp = 10
|
||||
}
|
||||
pri := a.Priority
|
||||
if pri == 0 {
|
||||
if m, ok := memByFullID[keeper]; ok {
|
||||
pri = int(m.Priority)
|
||||
}
|
||||
}
|
||||
if err := t.memClient.Update(ctx, &model.MemoryEntry{
|
||||
ID: keeper,
|
||||
Content: a.Content,
|
||||
Category: cat,
|
||||
Importance: imp,
|
||||
Priority: model.MemoryPriority(pri),
|
||||
Keywords: a.Keywords,
|
||||
Source: "consolidated",
|
||||
}); err != nil {
|
||||
log.Printf("[记忆整理] 更新合并目标 %s 失败: %v", keeper, err)
|
||||
continue
|
||||
}
|
||||
// Delete the discarded ones
|
||||
for _, did := range resolved[1:] {
|
||||
if err := t.memClient.Delete(ctx, did); err != nil {
|
||||
log.Printf("[记忆整理] 删除被合并记忆 %s 失败: %v", did, err)
|
||||
}
|
||||
}
|
||||
log.Printf("[记忆整理] 已合并: %v -> %s (原因: %s)", resolved, keeper, a.Reason)
|
||||
executed++
|
||||
|
||||
case "update":
|
||||
id := resolveID(a.ID, memByShortID, memByFullID)
|
||||
if id == "" {
|
||||
log.Printf("[记忆整理] 跳过 update: 找不到记忆 %s", a.ID)
|
||||
continue
|
||||
}
|
||||
existing := memByFullID[id]
|
||||
cat := model.MemoryCategory(a.Category)
|
||||
if cat == "" && existing != nil {
|
||||
cat = existing.Category
|
||||
}
|
||||
imp := a.Importance
|
||||
if imp == 0 && existing != nil {
|
||||
imp = existing.Importance
|
||||
}
|
||||
pri := a.Priority
|
||||
if pri == 0 && existing != nil {
|
||||
pri = int(existing.Priority)
|
||||
}
|
||||
if err := t.memClient.Update(ctx, &model.MemoryEntry{
|
||||
ID: id,
|
||||
Content: a.Content,
|
||||
Category: cat,
|
||||
Importance: imp,
|
||||
Priority: model.MemoryPriority(pri),
|
||||
Keywords: a.Keywords,
|
||||
Source: "consolidated",
|
||||
}); err != nil {
|
||||
log.Printf("[记忆整理] 更新 %s 失败: %v", id, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[记忆整理] 已更新: %s (原因: %s)", id, a.Reason)
|
||||
executed++
|
||||
|
||||
case "create":
|
||||
cat := model.MemoryCategory(a.Category)
|
||||
if cat == "" {
|
||||
cat = model.CategoryKnowledge
|
||||
}
|
||||
imp := a.Importance
|
||||
if imp == 0 {
|
||||
imp = 5
|
||||
}
|
||||
if err := t.memClient.Save(ctx, &model.MemoryEntry{
|
||||
UserID: t.adminUserID,
|
||||
Content: a.Content,
|
||||
Category: cat,
|
||||
Importance: imp,
|
||||
Priority: model.MemoryNormal,
|
||||
Keywords: a.Keywords,
|
||||
Source: "consolidation",
|
||||
}); err != nil {
|
||||
log.Printf("[记忆整理] 创建记忆失败: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[记忆整理] 已创建: %s (原因: %s)", a.Content, a.Reason)
|
||||
executed++
|
||||
}
|
||||
}
|
||||
return executed
|
||||
}
|
||||
|
||||
// resolveID tries to match a short or full ID to an existing memory.
|
||||
func resolveID(id string, byShort, byFull map[string]*model.MemoryEntry) string {
|
||||
if _, ok := byFull[id]; ok {
|
||||
return id
|
||||
}
|
||||
if m, ok := byShort[id]; ok {
|
||||
return m.ID
|
||||
}
|
||||
// Try prefix match
|
||||
for fullID := range byFull {
|
||||
if strings.HasPrefix(fullID, id) {
|
||||
return fullID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// fuzzyMemorySearch expands the query via LLM keyword extraction and performs semantic search.
|
||||
func (t *Thinker) fuzzyMemorySearch(ctx context.Context, userID, query string) []memory.MemoryEntry {
|
||||
if t.memClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
keywords := t.expandMemoryKeywords(ctx, query)
|
||||
if len(keywords) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("[后台思考] 模糊记忆关键词: %v", keywords)
|
||||
|
||||
var allResults []memory.MemoryEntry
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, kw := range keywords {
|
||||
results, err := t.memClient.QueryByText(ctx, userID, kw, "", 0, 5)
|
||||
if err != nil {
|
||||
log.Printf("[后台思考] 模糊搜索 '%s' 失败: %v", kw, err)
|
||||
continue
|
||||
}
|
||||
for _, m := range results {
|
||||
if !seen[m.ID] {
|
||||
seen[m.ID] = true
|
||||
allResults = append(allResults, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allResults
|
||||
}
|
||||
|
||||
// expandMemoryKeywords uses LLM to generate fuzzy/related keywords for memory search.
|
||||
func (t *Thinker) expandMemoryKeywords(ctx context.Context, message string) []string {
|
||||
prompt := fmt.Sprintf(
|
||||
"从以下对话消息中提取 3-5 个可用于模糊搜索记忆的关键词。这些关键词应该是:\n"+
|
||||
"- 与话题相关的抽象概念\n- 同义词和相关词\n- 更宽泛或更具体的相关概念\n"+
|
||||
"- 不要包含消息中已经出现的原词\n\n"+
|
||||
"用户消息:「%s」\n\n"+
|
||||
"只输出 JSON 字符串数组,例如:[\"关键词1\",\"关键词2\"]", message)
|
||||
|
||||
resp, err := t.llmAdapter.Chat(ctx, []model.LLMMessage{
|
||||
{Role: model.RoleSystem, Content: "你是记忆搜索专家。输出 JSON 字符串数组。"},
|
||||
{Role: model.RoleUser, Content: prompt},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[后台思考] 关键词扩展失败: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(resp.Content)
|
||||
if idx := strings.Index(text, "["); idx >= 0 {
|
||||
if end := strings.LastIndex(text, "]"); end > idx {
|
||||
text = text[idx : end+1]
|
||||
}
|
||||
}
|
||||
|
||||
var keywords []string
|
||||
if err := json.Unmarshal([]byte(text), &keywords); err != nil {
|
||||
log.Printf("[后台思考] 解析关键词 JSON 失败: %v (raw=%s)", err, resp.Content)
|
||||
return nil
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
|
||||
// lastUserMessage extracts the last user message from conversation history.
|
||||
func lastUserMessage(history []model.LLMMessage) string {
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
if history[i].Role == model.RoleUser {
|
||||
runes := []rune(history[i].Content)
|
||||
if len(runes) > 200 {
|
||||
return string(runes[:200])
|
||||
}
|
||||
return history[i].Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatDeviceContext 格式化设备状态为文本
|
||||
func formatDeviceContext(devices []tools.IoTDevice) string {
|
||||
if len(devices) == 0 {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ScheduleRule defines a time-based interval rule.
|
||||
type ScheduleRule struct {
|
||||
Name string `json:"name"`
|
||||
Days []string `json:"days"`
|
||||
TimeRange string `json:"time_range"`
|
||||
Except []string `json:"except"`
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
}
|
||||
|
||||
// ThinkingScheduleConfig is the full schedule configuration.
|
||||
type ThinkingScheduleConfig struct {
|
||||
Version string `json:"version"`
|
||||
DefaultIntervalMinutes int `json:"default_interval_minutes"`
|
||||
Rules []ScheduleRule `json:"rules"`
|
||||
}
|
||||
|
||||
// ScheduleLoader loads the thinking schedule from a JSON file and calculates
|
||||
// the current interval based on time of day and day of week.
|
||||
type ScheduleLoader struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
config *ThinkingScheduleConfig
|
||||
}
|
||||
|
||||
// NewScheduleLoader creates a loader. Returns nil config if the file does not exist.
|
||||
func NewScheduleLoader(path string) (*ScheduleLoader, error) {
|
||||
l := &ScheduleLoader{path: path}
|
||||
if err := l.load(); err != nil {
|
||||
return l, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *ScheduleLoader) load() error {
|
||||
data, err := os.ReadFile(l.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
l.config = nil
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read thinking schedule: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
l.config = nil
|
||||
return nil
|
||||
}
|
||||
var cfg ThinkingScheduleConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
l.config = nil
|
||||
return fmt.Errorf("parse thinking schedule: %w", err)
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.config = &cfg
|
||||
l.mu.Unlock()
|
||||
log.Printf("[思考调度] 已加载配置文件: version=%s, default=%dmin, rules=%d", cfg.Version, cfg.DefaultIntervalMinutes, len(cfg.Rules))
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasConfig returns true if a schedule config was loaded from file.
|
||||
func (l *ScheduleLoader) HasConfig() bool {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
return l.config != nil
|
||||
}
|
||||
|
||||
// GetInterval returns the thinking interval in minutes for the given time.
|
||||
// Returns 0 if no schedule is loaded (caller should use default).
|
||||
func (l *ScheduleLoader) GetInterval(now time.Time) int {
|
||||
l.mu.RLock()
|
||||
defer l.mu.RUnlock()
|
||||
|
||||
if l.config == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
weekday := strings.ToLower(now.Weekday().String()) // monday, tuesday, ...
|
||||
currentMinutes := now.Hour()*60 + now.Minute()
|
||||
|
||||
for _, rule := range l.config.Rules {
|
||||
if !matchDay(weekday, rule.Days) {
|
||||
continue
|
||||
}
|
||||
if !matchTimeRange(currentMinutes, rule.TimeRange) {
|
||||
continue
|
||||
}
|
||||
if matchExceptRange(currentMinutes, rule.Except) {
|
||||
continue
|
||||
}
|
||||
return rule.IntervalMinutes
|
||||
}
|
||||
|
||||
return l.config.DefaultIntervalMinutes
|
||||
}
|
||||
|
||||
// matchDay checks if the current weekday is in the rule's days list.
|
||||
func matchDay(currentDay string, days []string) bool {
|
||||
for _, d := range days {
|
||||
if strings.ToLower(d) == currentDay {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchTimeRange checks if currentMinutes (0-1439) falls within the time range.
|
||||
// Supports overnight ranges (e.g., 23:00-07:00 where start > end).
|
||||
func matchTimeRange(currentMinutes int, timeRange string) bool {
|
||||
start, end, ok := parseTimeRange(timeRange)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if start <= end {
|
||||
return currentMinutes >= start && currentMinutes < end
|
||||
}
|
||||
// Overnight range
|
||||
return currentMinutes >= start || currentMinutes < end
|
||||
}
|
||||
|
||||
// matchExceptRange returns true if currentMinutes falls in any except range.
|
||||
func matchExceptRange(currentMinutes int, exceptRanges []string) bool {
|
||||
for _, er := range exceptRanges {
|
||||
start, end, ok := parseTimeRange(er)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start <= end {
|
||||
if currentMinutes >= start && currentMinutes < end {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if currentMinutes >= start || currentMinutes < end {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseTimeRange parses "HH:MM-HH:MM" into start and end minutes from midnight.
|
||||
func parseTimeRange(r string) (int, int, bool) {
|
||||
parts := strings.SplitN(r, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
start, ok := parseHM(strings.TrimSpace(parts[0]))
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
end, ok := parseHM(strings.TrimSpace(parts[1]))
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// parseHM parses "HH:MM" into minutes from midnight.
|
||||
func parseHM(s string) (int, bool) {
|
||||
parts := strings.SplitN(s, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
h, err := strconv.Atoi(parts[0])
|
||||
if err != nil || h < 0 || h > 23 {
|
||||
return 0, false
|
||||
}
|
||||
m, err := strconv.Atoi(parts[1])
|
||||
if err != nil || m < 0 || m > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return h*60 + m, true
|
||||
}
|
||||
Reference in New Issue
Block a user