Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c8d9c8c
feat: improve proactive context and dashboard integration
Justice-ocr Jun 6, 2026
95f4397
fix: tolerate fastapi starlette router mismatch
Justice-ocr Jun 6, 2026
214eb08
fix: use astrbot page bridge safely
Justice-ocr Jun 6, 2026
d09d1f6
feat: run full webui inside astrbot pages
Justice-ocr Jun 6, 2026
33a0415
fix: load page assets through static tags
Justice-ocr Jun 6, 2026
c461ce5
fix: preserve page asset tokens in loader
Justice-ocr Jun 6, 2026
80ff7b1
fix: bundle proactive pages webui statically
Justice-ocr Jun 6, 2026
e2e676c
fix: force page vendor scripts to use globals
Justice-ocr Jun 6, 2026
3d6a479
fix: harden pages boot script loading
Justice-ocr Jun 6, 2026
a6d22f6
fix: avoid missed auth-ready page render
Justice-ocr Jun 6, 2026
d96cd6f
fix: render pages immediately and surface runtime failures
Justice-ocr Jun 6, 2026
306b3ff
fix: wait for page app mount before clearing boot
Justice-ocr Jun 6, 2026
a180d1a
fix: rerender pages app for reused window roots
Justice-ocr Jun 6, 2026
ce08b66
fix: bust pages app bundle cache
Justice-ocr Jun 6, 2026
0a81df7
fix: harden astrbot pages startup
Justice-ocr Jun 6, 2026
dead044
fix: do not hard fail when pages bridge sdk is absent
Justice-ocr Jun 6, 2026
824e492
fix: transpile pages app for older webviews
Justice-ocr Jun 6, 2026
9cff288
fix: prevent pages app rerun from breaking context
Justice-ocr Jun 6, 2026
a265f07
fix: replace pages webui with native console
Justice-ocr Jun 6, 2026
8e876ad
fix: restore native pages config editor
Justice-ocr Jun 6, 2026
135ceb0
fix: show fallback countdown timers on pages
Justice-ocr Jun 6, 2026
d4fbd76
fix: persist pages config saves
Justice-ocr Jun 6, 2026
822cec4
fix: sync config controls before save
Justice-ocr Jun 6, 2026
d75caf0
fix: keep edited config after saving
Justice-ocr Jun 6, 2026
57ad0cf
fix: read pages config save body
Justice-ocr Jun 6, 2026
2fa06b5
fix: use dedicated pages config save endpoints
Justice-ocr Jun 6, 2026
b8ab5f7
fix: align pages config save with aiimg
Justice-ocr Jun 6, 2026
bca0b34
fix: confirm pages config save reaches backend
Justice-ocr Jun 6, 2026
19a4566
fix: improve pages realtime and save verification
Justice-ocr Jun 6, 2026
846fa2e
fix: show pending session timers in pages
Justice-ocr Jun 6, 2026
305186e
fix: show pages logo and pending tasks
Justice-ocr Jun 6, 2026
49cd73e
fix: refine pages task and timer counts
Justice-ocr Jun 6, 2026
bd9f23e
fix: add contextual proactive scheduling
Justice-ocr Jun 6, 2026
f1d1202
fix: address pr review robustness issues
Justice-ocr Jun 6, 2026
1ff7cbf
Merge pull request #1 from Justice-ocr/codex/ai-pages-contextual-sche…
Justice-ocr Jun 6, 2026
6156e27
fix: guard duplicate proactive sends
Justice-ocr Jun 7, 2026
1995fe8
fix: count scheduled page timers
Justice-ocr Jun 7, 2026
8f2c0c6
fix: dedupe proactive chat targets
Justice-ocr Jun 7, 2026
800add3
fix: decode page route session ids
Justice-ocr Jun 7, 2026
073d9db
fix: hide capped unanswered jobs
Justice-ocr Jun 7, 2026
0a19e3f
fix: initialize exception handler flag
Justice-ocr Jun 7, 2026
c2230b2
fix: ignore bot private messages
Justice-ocr Jun 7, 2026
a02544f
fix: delay proactive sends after external bot messages
Justice-ocr Jun 7, 2026
cdade41
fix: detect tool-sent bot messages before proactive send
Justice-ocr Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions _conf_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@
"default": true,
"hint": "开启后,会把 Bot 自己在平台中的历史发言也一起提供给模型;关闭后,只提供用户侧聊天内容。开启后更有利于模型理解最近对话来回过程,但也可能让上下文更长。"
},
"include_context_insights": {
"description": "生成对话态势摘要",
"type": "bool",
"default": true,
"hint": "开启后,插件会根据最近聊天生成一段轻量态势摘要,帮助模型判断应该延续话题、轻量问候还是克制发言。"
},
"bot_identifiers": {
"description": "Bot 标识符列表(逗号分隔)",
"type": "string",
Expand Down Expand Up @@ -121,6 +127,23 @@
"step": 30
}
},
"enable_contextual_timing": {
"description": "启用语境触发时间预测",
"type": "bool",
"default": true,
"hint": "开启后会根据最近消息中的明显语义调整下一次触发时间,例如晚安、看电影、开会、在路上等;没有识别到明确语境时仍使用随机区间"
},
"contextual_timing_history_count": {
"description": "语境预测读取消息条数",
"type": "int",
"default": 8,
"hint": "用于判断下一次触发时间的最近消息条数。数值越大越容易参考更早的上下文,但也可能引入无关内容",
"slider": {
"min": 1,
"max": 30,
"step": 1
}
},
"quiet_hours": {
"description": "免打扰时段 (24小时制)",
"type": "string",
Expand Down Expand Up @@ -364,6 +387,12 @@
"default": true,
"hint": "开启后,会把 Bot 自己在平台中的历史发言也一起提供给模型;关闭后,只提供用户侧聊天内容。开启后更有利于模型理解最近对话来回过程,但也可能让上下文更长。"
},
"include_context_insights": {
"description": "生成对话态势摘要",
"type": "bool",
"default": true,
"hint": "开启后,插件会根据最近聊天生成一段轻量态势摘要,帮助模型判断应该延续话题、抛出开放问题还是减少刷屏感。"
},
"bot_identifiers": {
"description": "Bot 标识符列表(逗号分隔)",
"type": "string",
Expand Down Expand Up @@ -398,6 +427,23 @@
"step": 30
}
},
"enable_contextual_timing": {
"description": "启用语境触发时间预测",
"type": "bool",
"default": true,
"hint": "开启后会根据最近消息中的明显语义调整下一次触发时间,例如晚安、看电影、开会、在路上等;没有识别到明确语境时仍使用随机区间"
},
"contextual_timing_history_count": {
"description": "语境预测读取消息条数",
"type": "int",
"default": 8,
"hint": "用于判断下一次触发时间的最近消息条数。数值越大越容易参考更早的上下文,但也可能引入无关内容",
"slider": {
"min": 1,
"max": 30,
"step": 1
}
},
"quiet_hours": {
"description": "免打扰时段 (24小时制)",
"type": "string",
Expand Down
131 changes: 97 additions & 34 deletions core/chat_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from __future__ import annotations

import asyncio
import random
import time
from datetime import datetime
from typing import Any
Expand All @@ -24,10 +23,27 @@ class ProactiveCoreMixin:
data_lock: Any
session_data: dict
last_message_times: dict[str, float]
last_external_bot_message_times: dict[str, float]
telemetry: Any
manual_trigger_sessions: set[str]
active_chat_sessions: set[str]
web_admin_server: Any

def _get_chat_guard_key(self, session_id: str) -> str:
"""Return a platform-independent key for one logical chat target."""
parsed = self._parse_session_id(session_id)
if not parsed:
return self._normalize_session_id(session_id)

_platform, message_type, target_id = parsed
if "Friend" in message_type or "Private" in message_type:
chat_scope = "private"
elif "Group" in message_type or "Guild" in message_type:
chat_scope = "group"
else:
chat_scope = message_type or "unknown"
return f"{chat_scope}:{target_id}"

async def _clear_manual_trigger_state(self, session_id: str) -> None:
"""释放指定会话的手动触发占用状态,并向管理端广播任务刷新。"""
normalized_session_id = self._normalize_session_id(session_id)
Expand Down Expand Up @@ -88,6 +104,16 @@ async def _finalize_and_reschedule(
)
session_config = None
scheduled_job_payload = None
scheduled_plan = None

if is_private_session:
session_config = self._get_session_config(session_id)
if not session_config:
return
scheduled_plan = await self._build_next_schedule_plan(
session_id,
session_config,
)

async with self.data_lock:
# 更新未回复计数器
Expand All @@ -100,36 +126,22 @@ async def _finalize_and_reschedule(
f"[主动消息] {self._get_session_log_str(session_id)} 的第 {new_unanswered_count} 次主动消息已发送完成,当前未回复次数: {new_unanswered_count} 次喵。"
)

# 私聊任务:锁内仅计算调度参数并写入持久化字段,避免在持锁期间操作调度器。
if is_private_session:
session_config = self._get_session_config(session_id)
if not session_config:
return

schedule_conf = session_config.get("schedule_settings", {})
min_interval = int(schedule_conf.get("min_interval_minutes", 30)) * 60
max_interval = max(
min_interval,
int(schedule_conf.get("max_interval_minutes", 900)) * 60,
if (
is_private_session
and scheduled_plan
and not self._is_unanswered_limit_reached(
session_id, session_config, new_unanswered_count
)
# 私聊采用配置区间内随机间隔,减少触发规律性
random_interval = random.randint(min_interval, max_interval)
scheduled_at = time.time()
next_trigger_time = scheduled_at + random_interval
run_date = datetime.fromtimestamp(next_trigger_time, tz=self.timezone)

):
session_payload = self.session_data.setdefault(session_id, {})
session_payload["next_trigger_time"] = next_trigger_time
session_payload["last_scheduled_at"] = scheduled_at
session_payload["last_schedule_min_interval_seconds"] = min_interval
session_payload["last_schedule_max_interval_seconds"] = max_interval
session_payload["last_schedule_random_interval_seconds"] = (
random_interval
)
self._write_schedule_plan_to_session(session_payload, scheduled_plan)
scheduled_job_payload = {
"run_date": run_date,
"run_date": scheduled_plan["run_date"],
"session_config": session_config,
}
elif is_private_session:
session_payload = self.session_data.setdefault(session_id, {})
self._clear_session_schedule_state(session_id)

await self._save_data_internal()

Expand All @@ -150,6 +162,15 @@ async def _finalize_and_reschedule(
async def check_and_chat(self, session_id: str) -> None:
"""由定时任务触发的核心函数,完成一次完整的主动消息流程。"""
normalized_session_id = self._normalize_session_id(session_id)
chat_guard_key = self._get_chat_guard_key(normalized_session_id)
if chat_guard_key in self.active_chat_sessions:
logger.info(
f"[主动消息] {self._get_session_log_str(normalized_session_id)} 已有主动消息任务正在执行,跳过重复触发喵。"
)
await self._clear_manual_trigger_state(normalized_session_id)
return

self.active_chat_sessions.add(chat_guard_key)
try:
# 免打扰与启用状态检查
is_allowed, block_reason = await self._is_chat_allowed(
Expand Down Expand Up @@ -184,13 +205,25 @@ async def check_and_chat(self, session_id: str) -> None:
unanswered_count = self.session_data.get(normalized_session_id, {}).get(
"unanswered_count", 0
)
max_unanswered = schedule_conf.get("max_unanswered_times", 3)
if max_unanswered > 0 and unanswered_count >= max_unanswered:
max_unanswered = self._get_max_unanswered_count(session_config)
if self._is_unanswered_limit_reached(
normalized_session_id, session_config, unanswered_count
):
self._purge_related_jobs(normalized_session_id)
if self._clear_session_schedule_state(normalized_session_id):
await self._save_data_internal()
logger.info(
f"[主动消息] {self._get_session_log_str(normalized_session_id, session_config)} 的未回复次数 ({unanswered_count}) 已达到上限 ({max_unanswered}),暂停主动消息喵。"
)
return

if await self._delay_if_recent_external_bot_message(
normalized_session_id,
session_config,
):
await self._clear_manual_trigger_state(normalized_session_id)
return

logger.info(
f"[主动消息] 开始生成第 {unanswered_count + 1} 次主动消息喵,当前未回复次数: {unanswered_count} 次喵。"
)
Expand Down Expand Up @@ -221,11 +254,18 @@ async def check_and_chat(self, session_id: str) -> None:
system_prompt = request_package["system_prompt"]
# 可能使用规范化后的会话 ID(由上下文准备阶段返回)
session_id = request_package.get("session_id", session_id)
state_session_id = self._normalize_session_id(session_id)

# 记录任务开始状态快照
# 用于检测 LLM 生成窗口内是否出现用户新消息
task_start_state = {
"last_message_time": self.last_message_times.get(session_id, 0),
"last_message_time": max(
self.last_message_times.get(session_id, 0),
self.last_message_times.get(state_session_id, 0),
),
"external_bot_message_time": self._get_external_bot_message_time(
state_session_id
),
"unanswered_count": unanswered_count,
"timestamp": time.time(),
}
Expand All @@ -244,23 +284,45 @@ async def check_and_chat(self, session_id: str) -> None:

# 检查生成期间是否有新消息
current_state = {
"last_message_time": self.last_message_times.get(session_id, 0),
"unanswered_count": self.session_data.get(session_id, {}).get(
"unanswered_count", 0
"last_message_time": max(
self.last_message_times.get(session_id, 0),
self.last_message_times.get(state_session_id, 0),
),
"external_bot_message_time": self._get_external_bot_message_time(
state_session_id
),
"unanswered_count": self.session_data.get(state_session_id, {}).get(
"unanswered_count",
self.session_data.get(session_id, {}).get("unanswered_count", 0),
),
}

# 任一条件命中都代表“用户已有新动作”,本次生成结果需丢弃
has_new_message = (
current_state["last_message_time"]
> task_start_state["last_message_time"]
or current_state["external_bot_message_time"]
> task_start_state["external_bot_message_time"]
or current_state["unanswered_count"]
< task_start_state["unanswered_count"]
!= task_start_state["unanswered_count"]
)

if has_new_message:
if (
current_state["external_bot_message_time"]
> task_start_state["external_bot_message_time"]
):
delayed = await self._delay_schedule_for_external_bot_message(
state_session_id,
current_state["external_bot_message_time"],
)
if not delayed:
await self._schedule_next_chat_and_save(
state_session_id,
reset_counter=False,
)
logger.info(
"[主动消息] 检测到用户在LLM生成期间发送了新消息,丢弃本次主动消息喵。"
"[主动消息] 检测到用户或其它插件在LLM生成期间发送了新消息,丢弃本次主动消息喵。"
)
return

Expand Down Expand Up @@ -325,4 +387,5 @@ async def check_and_chat(self, session_id: str) -> None:
)
)
finally:
self.active_chat_sessions.discard(chat_guard_key)
await self._clear_manual_trigger_state(normalized_session_id)
16 changes: 16 additions & 0 deletions core/data_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ def _merge_session_info(self, base: dict, incoming: dict) -> dict:
"last_schedule_min_interval_seconds",
"last_schedule_max_interval_seconds",
"last_schedule_random_interval_seconds",
"last_schedule_strategy",
"last_schedule_reason",
"last_schedule_rule",
"last_schedule_source",
"last_schedule_history_session_id",
"last_schedule_history_conv_id",
"last_schedule_history_count",
"last_schedule_history_last_role",
]:
if key not in incoming:
continue
Expand All @@ -104,6 +112,14 @@ def _merge_session_info(self, base: dict, incoming: dict) -> dict:
"last_schedule_min_interval_seconds",
"last_schedule_max_interval_seconds",
"last_schedule_random_interval_seconds",
"last_schedule_strategy",
"last_schedule_reason",
"last_schedule_rule",
"last_schedule_source",
"last_schedule_history_session_id",
"last_schedule_history_conv_id",
"last_schedule_history_count",
"last_schedule_history_last_role",
}:
base_scheduled_at = merged.get("last_scheduled_at")
incoming_scheduled_at = incoming.get("last_scheduled_at")
Expand Down
Loading