91c9ee4b2d
广播逻辑重构: - 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>
185 lines
4.5 KiB
Go
185 lines
4.5 KiB
Go
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
|
|
}
|