From 7ef8c662bf6a580f102730d179cf041336a9ae08 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 6 Jun 2026 17:16:16 +0800 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20GitHub=20watchers=20=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E9=94=99=E8=AF=AF=EF=BC=8C=E5=BA=94=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=20subscribers=5Fcount=20=E8=80=8C=E9=9D=9E=20watchers=5Fcount?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHub API 的 watchers_count 实际等于 stargazers_count(历史遗留) - 真正的 watchers(订阅数)应使用 subscribers_count 字段 - 更新 GitHubRepoInfo.watchers 类型为 int | None - 补充测试验证 watchers 和 subscribers 字段正确性 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Undefined/github/client.py | 2 +- src/Undefined/github/models.py | 2 +- tests/test_github_client.py | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Undefined/github/client.py b/src/Undefined/github/client.py index 574c5ef5..6886725c 100644 --- a/src/Undefined/github/client.py +++ b/src/Undefined/github/client.py @@ -112,7 +112,7 @@ def _parse_repo_info( stars=_as_int(payload.get("stargazers_count")), forks=_as_int(payload.get("forks_count")), open_issues=_as_int(payload.get("open_issues_count")), - watchers=_as_int(payload.get("watchers_count")), + watchers=_as_optional_int(payload.get("subscribers_count")), subscribers=_as_optional_int(payload.get("subscribers_count")), contributors=contributor_count, language=_as_str(payload.get("language")), diff --git a/src/Undefined/github/models.py b/src/Undefined/github/models.py index 4724c3be..50607bf7 100644 --- a/src/Undefined/github/models.py +++ b/src/Undefined/github/models.py @@ -19,7 +19,7 @@ class GitHubRepoInfo: stars: int forks: int open_issues: int - watchers: int + watchers: int | None subscribers: int | None contributors: int | None language: str diff --git a/tests/test_github_client.py b/tests/test_github_client.py index 08ecdc7e..22832900 100644 --- a/tests/test_github_client.py +++ b/tests/test_github_client.py @@ -78,6 +78,8 @@ async def fake_request_with_retry( assert info.stars == 1234 assert info.forks == 56 assert info.open_issues == 7 + assert info.watchers == 89 + assert info.subscribers == 89 assert info.contributors == 42 assert info.topics == ("bot", "onebot") From 886a057ca55b957f30023f4c4a2b59c2667cd513 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 6 Jun 2026 19:33:48 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat(webui):=20=E6=96=B0=E5=A2=9E=20autosta?= =?UTF-8?q?rt=5Fbot=20=E9=85=8D=E7=BD=AE=EF=BC=8CWebUI=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=E8=87=AA=E5=8A=A8=E6=8B=89=E8=B5=B7=20Bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 [webui].autostart_bot 配置项(默认 false,保持现有行为) - on_startup 钩子中:自动恢复标记优先,标记不存在时按配置自动启动 - 启动失败只记录日志,不阻塞 WebUI 启动;与自动恢复机制互不冲突 - 同步更新 config_class/domains 配置流转与 WebUISettings 数据模型 - 同步更新文档:CLAUDE.md、configuration/webui-guide/deployment/usage - 补充测试:test_webui_settings(7) + test_webui_autostart(5) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 5 + config.toml.example | 3 + docs/configuration.md | 7 +- docs/deployment.md | 12 ++ docs/usage.md | 2 + docs/webui-guide.md | 16 ++ src/Undefined/config/config_class.py | 1 + src/Undefined/config/load_sections/domains.py | 1 + src/Undefined/config/webui_settings.py | 8 +- src/Undefined/webui/app.py | 14 +- tests/test_webui_autostart.py | 168 ++++++++++++++++++ tests/test_webui_settings.py | 163 +++++++++++++++++ 12 files changed, 396 insertions(+), 4 deletions(-) create mode 100644 tests/test_webui_autostart.py create mode 100644 tests/test_webui_settings.py diff --git a/CLAUDE.md b/CLAUDE.md index 13424c98..5987225d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,11 @@ uv run playwright install # 页面截图等能力依赖的浏览器运行 uv run Undefined-webui # 启动 Management-first WebUI(推荐入口) uv run Undefined # 直接启动 Bot +# WebUI 自动启动 Bot +# 配置 [webui].autostart_bot = true 后,运行 uv run Undefined-webui 会自动拉起 bot 进程 +# 默认为 false,保持传统手动启动行为 +# 注意:该配置仅在 WebUI 启动时生效,运行时修改需重启 WebUI + # 代码质量(提交前必须全部通过) uv run ruff format . uv run ruff check . diff --git a/config.toml.example b/config.toml.example index 423bb847..340e6a58 100644 --- a/config.toml.example +++ b/config.toml.example @@ -1234,6 +1234,9 @@ port = 8787 # zh: WebUI 密码(首次启动必须修改默认值;默认密码无法登录)。 # en: WebUI password (must be changed on first run; default password cannot be used to log in). password = "changeme" +# zh: WebUI 启动时是否自动启动机器人进程。 +# en: Auto-start bot when WebUI starts. +autostart_bot = false # zh: 主进程 Runtime API(供 WebUI/外部系统读取探针、记忆检索、AI Chat 使用)。 # en: Runtime API in the main process (for WebUI/external integrations: probes, memory queries, AI chat). diff --git a/docs/configuration.md b/docs/configuration.md index cefbcc04..ce667066 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -839,11 +839,13 @@ Prompt caching 补充: | `url` | `127.0.0.1` | WebUI 监听地址 | | `port` | `8787` | WebUI 端口(1..65535) | | `password` | `changeme` | WebUI 登录密码 | +| `autostart_bot` | `false` | WebUI 启动时是否自动启动机器人进程 | 关键行为: - 默认密码 `changeme` 禁止登录,必须先修改。 -- 未配置或为空时,会回退默认密码并标记为“默认密码模式”。 -- `webui.url/port/password` 修改需重启 WebUI 进程(机器人主进程中也属于重启生效类)。 +- 未配置或为空时,会回退默认密码并标记为”默认密码模式”。 +- `webui.url/port/password/autostart_bot` 修改需重启 WebUI 进程(机器人主进程中也属于重启生效类)。 +- `autostart_bot=true` 时,运行 `uv run Undefined-webui` 会自动拉起 bot 进程,无需手动点击启动按钮;与 WebUI 更新重启后的自动恢复机制(`pending_bot_autostart` marker)互不冲突。 --- @@ -1027,6 +1029,7 @@ Prompt caching 补充: - `webui.url` - `webui.port` - `webui.password` +- `webui.autostart_bot` - `api.*`(`enabled/host/port/auth_key/openapi_enabled`) - `memes.blob_dir` - `memes.preview_dir` diff --git a/docs/deployment.md b/docs/deployment.md index a042c25d..6382d7c1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -89,6 +89,17 @@ uv run Undefined-webui > > WebUI 功能详见 [WebUI 使用指南](webui-guide.md)。 +#### 自动启动选项 + +若希望 WebUI 启动后自动拉起机器人进程,可在 `config.toml` 中设置: + +```toml +[webui] +autostart_bot = true +``` + +这样运行 `uv run Undefined-webui` 时会自动启动 bot,无需手动操作。默认为 `false`。 + ### 6. 跨平台与资源路径(重要) - **资源读取**:运行时会优先从运行目录加载同名 `res/...` / `img/...`(便于覆盖),若不存在再使用安装包自带资源;并提供仓库结构兜底查找,因此从任意目录启动也能正常加载提示词与资源文案。 @@ -149,6 +160,7 @@ Undefined-webui > > - 选择 `Undefined`:直接在终端运行机器人,修改 `config.toml` 后重启生效(或依赖热重载能力)。 > - 选择 `Undefined-webui`:启动后访问 WebUI(默认 `http://127.0.0.1:8787`,密码默认 `changeme`;**首次启动必须修改默认密码,默认密码不可登录**;可在 `config.toml` 的 `[webui]` 中修改),在 WebUI 中在线编辑/校验配置,并通过 WebUI 启动/停止机器人进程。 +> - 若希望 `Undefined-webui` 启动后自动拉起机器人进程,可在 `config.toml` 的 `[webui]` 中设置 `autostart_bot = true`(默认 `false`)。 > `Undefined-webui` 会在检测到当前目录缺少 `config.toml` 时,自动从 `config.toml.example` 生成一份,便于直接在 WebUI 中修改。 > 提示:资源文件已随包发布,支持在非项目根目录启动;如需自定义内容,请参考上方源码部署的自定义指南。 diff --git a/docs/usage.md b/docs/usage.md index 8cb658fc..37e22e40 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -402,6 +402,8 @@ Undefined 提供了一套完整的可视化管理控制台,无需修改配置 WebUI 通过浏览器访问(默认地址 `http://127.0.0.1:8787`,默认密码 `changeme`,**首次启动必须在 `config.toml` 的 `[webui]` 中修改默认密码**)。如需通过手机或其他设备进行远程管理,可使用配套的多端控制台 App,详见 [《跨平台控制台 App》](app.md)。 +> **自动启动 Bot**:WebUI 支持配置 `[webui].autostart_bot = true` 实现启动时自动拉起机器人进程,详见 [WebUI 使用指南](webui-guide.md)。 + --- *如需查阅各模块的底层设计原理与 API 集成说明,请参阅本目录下的其余技术文档。* diff --git a/docs/webui-guide.md b/docs/webui-guide.md index 42fdc361..fe3745c5 100644 --- a/docs/webui-guide.md +++ b/docs/webui-guide.md @@ -19,6 +19,7 @@ uv run Undefined-webui url = "127.0.0.1" # 监听地址 port = 8787 # 端口 password = "changeme" # 密码(必须在首次登录时修改) +autostart_bot = false # 启动 WebUI 时是否自动启动 Bot(默认 false) ``` > 如需远程访问,将 `url` 改为 `0.0.0.0` 或实际 IP。 @@ -140,6 +141,21 @@ WebUI 首页(Landing Page)提供 Bot 的启停控制: 首页还会检测是否有可用更新(基于 Git),并提供更新 + 重启功能。 +### 自动启动 Bot + +若希望 WebUI 启动后自动拉起 bot 进程,可在 `config.toml` 中配置: + +```toml +[webui] +autostart_bot = true +``` + +启用后,`uv run Undefined-webui` 会自动启动机器人,无需手动点击"启动 Bot"按钮。默认为 `false`。 + +**注意**: +- 该配置仅在 WebUI 启动时生效,运行时修改需重启 WebUI 才能应用。 +- 与 WebUI 更新重启后的自动恢复机制(`pending_bot_autostart` marker)互不冲突,自动恢复优先级更高。 + --- ## 键盘快捷键 diff --git a/src/Undefined/config/config_class.py b/src/Undefined/config/config_class.py index b29a786e..560063b1 100644 --- a/src/Undefined/config/config_class.py +++ b/src/Undefined/config/config_class.py @@ -129,6 +129,7 @@ class Config: webui_url: str webui_port: int webui_password: str + webui_autostart_bot: bool api: APIConfig # Code Delivery Agent code_delivery_enabled: bool diff --git a/src/Undefined/config/load_sections/domains.py b/src/Undefined/config/load_sections/domains.py index 6ada6453..ab4d8075 100644 --- a/src/Undefined/config/load_sections/domains.py +++ b/src/Undefined/config/load_sections/domains.py @@ -46,6 +46,7 @@ def load_domains( "webui_url": webui_settings.url, "webui_port": webui_settings.port, "webui_password": webui_settings.password, + "webui_autostart_bot": webui_settings.autostart_bot, "api": api_config, "cognitive": cognitive, "memes": memes, diff --git a/src/Undefined/config/webui_settings.py b/src/Undefined/config/webui_settings.py index 45a5392b..b5879e6c 100644 --- a/src/Undefined/config/webui_settings.py +++ b/src/Undefined/config/webui_settings.py @@ -6,11 +6,12 @@ from pathlib import Path from typing import Optional -from .coercers import _coerce_int, _coerce_str, _normalize_str, _get_value +from .coercers import _coerce_bool, _coerce_int, _coerce_str, _normalize_str, _get_value DEFAULT_WEBUI_URL = "127.0.0.1" DEFAULT_WEBUI_PORT = 8787 DEFAULT_WEBUI_PASSWORD = "changeme" +DEFAULT_WEBUI_AUTOSTART_BOT = False @dataclass @@ -18,6 +19,7 @@ class WebUISettings: url: str port: int password: str + autostart_bot: bool using_default_password: bool config_exists: bool @@ -37,11 +39,13 @@ def load_webui_settings(config_path: Optional[Path] = None) -> WebUISettings: url_value = _get_value(data, ("webui", "url"), None) port_value = _get_value(data, ("webui", "port"), None) password_value = _get_value(data, ("webui", "password"), None) + autostart_bot_value = _get_value(data, ("webui", "autostart_bot"), None) url = _coerce_str(url_value, DEFAULT_WEBUI_URL) port = _coerce_int(port_value, DEFAULT_WEBUI_PORT) if port <= 0 or port > 65535: port = DEFAULT_WEBUI_PORT + autostart_bot = _coerce_bool(autostart_bot_value, DEFAULT_WEBUI_AUTOSTART_BOT) password_normalized = _normalize_str(password_value) if not password_normalized: @@ -49,6 +53,7 @@ def load_webui_settings(config_path: Optional[Path] = None) -> WebUISettings: url=url, port=port, password=DEFAULT_WEBUI_PASSWORD, + autostart_bot=autostart_bot, using_default_password=True, config_exists=config_exists, ) @@ -56,6 +61,7 @@ def load_webui_settings(config_path: Optional[Path] = None) -> WebUISettings: url=url, port=port, password=password_normalized, + autostart_bot=autostart_bot, using_default_password=False, config_exists=config_exists, ) diff --git a/src/Undefined/webui/app.py b/src/Undefined/webui/app.py index 7b7f61a4..9e8ff5f2 100644 --- a/src/Undefined/webui/app.py +++ b/src/Undefined/webui/app.py @@ -225,18 +225,30 @@ async def on_startup(app: web.Application) -> None: get_config_manager().start_hot_reload() logger.info("[WebUI] 后台任务已启动(热重载)") + bot = app[BOT_APP_KEY] + + # 1. 优先检查自动恢复标记(现有逻辑) # If we restarted WebUI after an update and the bot was previously running, # auto-start it again. try: marker = Path("data/cache/pending_bot_autostart") if marker.exists(): marker.unlink(missing_ok=True) - bot = app[BOT_APP_KEY] await bot.start() logger.info("[WebUI] 检测到自动恢复标记,已尝试启动机器人进程") + return # 已启动,跳过后续检查 except Exception: logger.debug("[WebUI] 自动恢复机器人进程失败", exc_info=True) + # 2. 检查配置项(新增逻辑) + try: + settings = app[SETTINGS_APP_KEY] + if settings.autostart_bot: + await bot.start() + logger.info("[WebUI] 配置 autostart_bot=true,已自动启动机器人进程") + except Exception: + logger.debug("[WebUI] 自动启动机器人进程失败", exc_info=True) + async def on_shutdown(app: web.Application) -> None: bot = app[BOT_APP_KEY] diff --git a/tests/test_webui_autostart.py b/tests/test_webui_autostart.py new file mode 100644 index 00000000..8a77cdca --- /dev/null +++ b/tests/test_webui_autostart.py @@ -0,0 +1,168 @@ +"""Tests for WebUI bot autostart functionality.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp import web + +from Undefined.config.webui_settings import WebUISettings +from Undefined.webui.app import on_startup +from Undefined.webui.routes._shared import BOT_APP_KEY, SETTINGS_APP_KEY + + +def _make_app(bot: MagicMock, settings: WebUISettings) -> web.Application: + """构造仅含 bot 与 settings 的伪 app(on_startup 只做 app[KEY] 访问)。""" + return cast( + web.Application, + {BOT_APP_KEY: bot, SETTINGS_APP_KEY: settings}, + ) + + +def _make_bot() -> MagicMock: + """创建 mock BotProcessController。""" + bot = MagicMock() + bot.start = AsyncMock() + bot.status = MagicMock(return_value={"running": False}) + return bot + + +def _make_settings(*, autostart_bot: bool) -> WebUISettings: + """创建指定 autostart_bot 的配置。""" + return WebUISettings( + url="127.0.0.1", + port=8787, + password="test", + autostart_bot=autostart_bot, + using_default_password=False, + config_exists=True, + ) + + +@pytest.fixture(autouse=True) +def _patch_config_manager(monkeypatch: pytest.MonkeyPatch) -> None: + """统一 mock 配置管理器,避免真实热重载副作用。""" + manager = MagicMock() + manager.start_hot_reload = MagicMock() + monkeypatch.setattr("Undefined.webui.app.get_config_manager", lambda: manager) + + +@pytest.mark.asyncio +async def test_on_startup_with_autostart_enabled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试 autostart_bot=true 时调用 bot.start()。""" + bot = _make_bot() + app = _make_app(bot, _make_settings(autostart_bot=True)) + + # 确保 pending_bot_autostart marker 不存在 + marker_path = tmp_path / "pending_bot_autostart" + monkeypatch.setattr("Undefined.webui.app.Path", lambda _: marker_path) + + await on_startup(app) + + bot.start.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_on_startup_with_autostart_disabled( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试 autostart_bot=false 时不调用 bot.start()。""" + bot = _make_bot() + app = _make_app(bot, _make_settings(autostart_bot=False)) + + # 确保 pending_bot_autostart marker 不存在 + marker_path = tmp_path / "pending_bot_autostart" + monkeypatch.setattr("Undefined.webui.app.Path", lambda _: marker_path) + + await on_startup(app) + + bot.start.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_startup_recovery_marker_takes_priority( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试 pending_bot_autostart marker 优先于 autostart_bot 配置。""" + bot = _make_bot() + # 即使 autostart_bot=False,存在 marker 也应启动 bot + app = _make_app(bot, _make_settings(autostart_bot=False)) + + # 创建 pending_bot_autostart marker 文件 + marker_path = tmp_path / "data" / "cache" / "pending_bot_autostart" + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.touch() + + original_path = Path + + def mock_path_factory(path_str: str) -> Path: + if path_str == "data/cache/pending_bot_autostart": + return marker_path + return original_path(path_str) + + monkeypatch.setattr("Undefined.webui.app.Path", mock_path_factory) + + await on_startup(app) + + # 1. bot.start() 被调用(通过自动恢复标记) + bot.start.assert_awaited_once() + # 2. marker 文件被删除 + assert not marker_path.exists() + + +@pytest.mark.asyncio +async def test_on_startup_marker_does_not_double_start_with_autostart( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试 marker 命中后即 return,不会因 autostart 再次启动(仅启动一次)。""" + bot = _make_bot() + # marker 与 autostart 同时存在,应只启动一次 + app = _make_app(bot, _make_settings(autostart_bot=True)) + + marker_path = tmp_path / "data" / "cache" / "pending_bot_autostart" + marker_path.parent.mkdir(parents=True, exist_ok=True) + marker_path.touch() + + original_path = Path + + def mock_path_factory(path_str: str) -> Path: + if path_str == "data/cache/pending_bot_autostart": + return marker_path + return original_path(path_str) + + monkeypatch.setattr("Undefined.webui.app.Path", mock_path_factory) + + await on_startup(app) + + # 只启动一次(marker 分支命中后 return) + bot.start.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_on_startup_autostart_failure_does_not_block_webui( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """测试自动启动失败不会阻塞 WebUI 启动。""" + bot = _make_bot() + bot.start.side_effect = RuntimeError("Bot start failed") + app = _make_app(bot, _make_settings(autostart_bot=True)) + + # 确保 pending_bot_autostart marker 不存在 + marker_path = tmp_path / "pending_bot_autostart" + monkeypatch.setattr("Undefined.webui.app.Path", lambda _: marker_path) + + # 执行启动钩子(不应抛出异常) + await on_startup(app) + + # 验证 bot.start() 被调用(虽然失败了) + bot.start.assert_awaited_once() diff --git a/tests/test_webui_settings.py b/tests/test_webui_settings.py new file mode 100644 index 00000000..db07df48 --- /dev/null +++ b/tests/test_webui_settings.py @@ -0,0 +1,163 @@ +"""Tests for WebUI settings loading.""" + +from __future__ import annotations + +from pathlib import Path + +from Undefined.config.webui_settings import load_webui_settings + + +def test_load_webui_settings_with_autostart_bot_true(tmp_path: Path) -> None: + """测试 autostart_bot = true 时正确解析。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "0.0.0.0" +port = 8080 +password = "test123" +autostart_bot = true +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.url == "0.0.0.0" + assert settings.port == 8080 + assert settings.password == "test123" + assert settings.autostart_bot is True + assert settings.using_default_password is False + assert settings.config_exists is True + + +def test_load_webui_settings_with_autostart_bot_false(tmp_path: Path) -> None: + """测试 autostart_bot = false 时正确解析。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "mypassword" +autostart_bot = false +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.url == "127.0.0.1" + assert settings.port == 8787 + assert settings.password == "mypassword" + assert settings.autostart_bot is False + assert settings.using_default_password is False + assert settings.config_exists is True + + +def test_load_webui_settings_autostart_bot_missing(tmp_path: Path) -> None: + """测试 autostart_bot 缺失时默认为 false。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "test" +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is False # 默认值 + assert settings.using_default_password is False + assert settings.config_exists is True + + +def test_load_webui_settings_autostart_bot_with_default_password( + tmp_path: Path, +) -> None: + """测试密码为空时 autostart_bot 仍能正确解析。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "" +autostart_bot = true +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is True + assert settings.using_default_password is True + assert settings.password == "changeme" + + +def test_load_webui_settings_autostart_bot_string_true(tmp_path: Path) -> None: + """测试 autostart_bot 接受字符串 'true'。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "test" +autostart_bot = "true" +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is True + + +def test_load_webui_settings_autostart_bot_numeric(tmp_path: Path) -> None: + """测试 autostart_bot 接受数值 1/0。""" + config_file = tmp_path / "config.toml" + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "test" +autostart_bot = 1 +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is True + + config_file.write_text( + """ +[webui] +url = "127.0.0.1" +port = 8787 +password = "test" +autostart_bot = 0 +""", + encoding="utf-8", + ) + + settings = load_webui_settings(config_file) + + assert settings.autostart_bot is False + + +def test_load_webui_settings_no_config_file() -> None: + """测试配置文件不存在时的默认行为。""" + settings = load_webui_settings(Path("/nonexistent/config.toml")) + + assert settings.url == "127.0.0.1" + assert settings.port == 8787 + assert settings.password == "changeme" + assert settings.autostart_bot is False # 默认值 + assert settings.using_default_password is True + assert settings.config_exists is False From 9f324f0e94f206bf1dfdcb0cf0f1ac1a2571feff Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 6 Jun 2026 20:18:45 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(webui):=20=E4=BF=AE=E5=A4=8D=E9=98=BB?= =?UTF-8?q?=E5=A1=9E=20IO=20=E4=B8=8E=E6=96=87=E6=A1=A3=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.py: 使用 async_io.exists/delete_file 替代阻塞的 Path 操作 避免 on_startup 中的 marker.exists() 和 marker.unlink() 阻塞事件循环 - webui-guide.md: 移除 inline code 中的 trailing spaces (MD038) 将 `/faq `、`/changelog `、`/cl `、`/h ` 改为无空格形式 并在周围文本说明末尾需带空格的语义 - tests: 补充测试文件模块级 docstring,提升 docstring coverage Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/webui-guide.md | 2 +- src/Undefined/webui/app.py | 5 +++-- tests/test_webui_autostart.py | 6 +++++- tests/test_webui_settings.py | 6 +++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/webui-guide.md b/docs/webui-guide.md index b1ca5ce8..0a009b59 100644 --- a/docs/webui-guide.md +++ b/docs/webui-guide.md @@ -122,7 +122,7 @@ WebUI 内置的对话界面,直接与 Bot 的 AI 进行交互: - 右侧会话抽屉支持多对话管理:新建对话、切换对话、重命名对话和删除对话。桌面端默认折叠,鼠标移到右侧触发区会自动展开;移动端默认只显示“对话”按钮,点击后展开会话列表,切换会话后自动收起。新建成功后会显示提示,并短暂高亮新会话。多对话只作用于 WebChat,不影响 QQ 私聊 / 群聊历史。每个会话在后端保存为 `data/webchat/conversations/.json`,删除对话会删除对应 JSON 文件;如果仍有 WebChat job 运行或正在收尾落盘,删除和清空会被拒绝。 - WebChat 的 AI 视角始终是同一个虚拟私聊用户 `system#42`,权限仍为 `superadmin`。切换 WebUI 会话只切换后端提供给 AI 的当前 WebChat 历史,不改变 `user_id`、`sender_id` 或身份提示,因此 AI 不会把多个 WebUI 会话看成不同真实用户。 -- 输入框开头输入 `/` 时会从后端 `/api/v1/commands?scope=webui` 获取当前可用斜杠命令并在输入框上方展开补全面板。面板按命令名、别名、说明和用法即时筛选,支持点击选择,也支持方向键选择、`Enter` / `Tab` 填入;直接手打 `/faq `、`/changelog ` 或 alias `/cl ` 这类复合命令后,会切换为子命令补全并显示具体用法。命令数据尚未返回时面板显示“正在加载可用命令”,不会提前显示“未找到匹配命令”;输入 `/h ` 这类已命中 alias 但命令本身没有声明子命令的内容时,会显示命令帮助块,包含说明、用法、示例和别名,例如 `/h [命令名] [-t]`,便于继续补参数;只有命令或子命令确实没有匹配项时才显示无匹配提示。命令清单按 WebChat 的虚拟私聊执行身份过滤,因此不会提示当前 WebUI 会话实际不可用的命令。 +- 输入框开头输入 `/` 时会从后端 `/api/v1/commands?scope=webui` 获取当前可用斜杠命令并在输入框上方展开补全面板。面板按命令名、别名、说明和用法即时筛选,支持点击选择,也支持方向键选择、`Enter` / `Tab` 填入;直接手打 `/faq`、`/changelog` 或 alias `/cl` 这类复合命令后(命令末尾需带空格),会切换为子命令补全并显示具体用法。命令数据尚未返回时面板显示”正在加载可用命令”,不会提前显示”未找到匹配命令”;输入 `/h` 这类已命中 alias 但命令本身没有声明子命令的内容时(末尾带空格),会显示命令帮助块,包含说明、用法、示例和别名,例如 `/h [命令名] [-t]`,便于继续补参数;只有命令或子命令确实没有匹配项时才显示无匹配提示。命令清单按 WebChat 的虚拟私聊执行身份过滤,因此不会提示当前 WebUI 会话实际不可用的命令。 - 旧版 WebChat 历史会在首次打开时自动迁移到默认会话。迁移完成后会写入标记文件,之后不会重复迁移;即使删除该默认会话,也不会再从旧历史文件恢复。未选择会话的旧接口调用会按需创建一个空的默认会话以保持向后兼容。 - 会话标题先使用第一条用户消息的前若干字符作为临时标题;当会话已有首问和首答后,后端会使用 chat model 根据首问 + 首答生成正式标题。手动重命名的标题不会被自动生成覆盖;临时或生成失败的标题会在后续能处理时继续尝试。 - 支持文本、图片和文件消息。图片或文件可通过 `+` 选择,也可直接粘贴到输入框;粘贴只会加入待发送附件条,不会立即发送,点击发送或按 Enter 时才随同当前文本进入同一条 WebChat 消息。无待发送附件时输入框会占满可用宽度;添加附件后右侧预览轨道随数量平滑展开,图片显示缩略图,附件较多时输入框保持最小可用宽度并压缩预览卡片,避免输入区跳动。移动端会把引用和附件预览轨道放到输入框上方,保证正文输入和发送按钮不被挤压。 diff --git a/src/Undefined/webui/app.py b/src/Undefined/webui/app.py index 94afb28c..984e9ef6 100644 --- a/src/Undefined/webui/app.py +++ b/src/Undefined/webui/app.py @@ -10,6 +10,7 @@ from Undefined.config import load_webui_settings, get_config_manager, get_config from Undefined.utils.cors import is_allowed_cors_origin, normalize_origin +from Undefined.utils import io as async_io from .core import BotProcessController, SessionStore from .routes import routes from .routes._shared import ( @@ -240,8 +241,8 @@ async def on_startup(app: web.Application) -> None: # auto-start it again. try: marker = Path("data/cache/pending_bot_autostart") - if marker.exists(): - marker.unlink(missing_ok=True) + if await async_io.exists(marker): + await async_io.delete_file(marker) await bot.start() logger.info("[WebUI] 检测到自动恢复标记,已尝试启动机器人进程") return # 已启动,跳过后续检查 diff --git a/tests/test_webui_autostart.py b/tests/test_webui_autostart.py index 8a77cdca..2706ec47 100644 --- a/tests/test_webui_autostart.py +++ b/tests/test_webui_autostart.py @@ -1,4 +1,8 @@ -"""Tests for WebUI bot autostart functionality.""" +"""Tests for WebUI bot autostart functionality. + +This module tests the on_startup hook to ensure proper autostart behavior +based on the autostart_bot configuration and pending_bot_autostart marker. +""" from __future__ import annotations diff --git a/tests/test_webui_settings.py b/tests/test_webui_settings.py index db07df48..1f94d10b 100644 --- a/tests/test_webui_settings.py +++ b/tests/test_webui_settings.py @@ -1,4 +1,8 @@ -"""Tests for WebUI settings loading.""" +"""Tests for WebUI settings loading. + +This module tests the load_webui_settings function to ensure proper +parsing of the autostart_bot configuration field from config.toml. +""" from __future__ import annotations From 4658b746ac6dd01afab41ef5f4734642e6249afb Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 6 Jun 2026 21:44:21 +0800 Subject: [PATCH 4/5] fix(api): serialize webchat job lifecycle - route non-stream WebChat requests through the job manager - cancel WebChat jobs and title tasks on runtime shutdown - prevent duplicate title jobs and cancelled events - add regression tests and update API docs Tests: - uv run pytest tests/test_runtime_api_chat_jobs.py tests/test_runtime_api_chat_history.py tests/test_webchat_conversations.py tests/test_runtime_api_chat_stream.py tests/test_webui_runtime_chat_frontend.py - uv run ruff check src/Undefined/api/routes/chat.py src/Undefined/api/webchat_store.py src/Undefined/api/app.py src/Undefined/api/_openapi.py tests/test_runtime_api_chat_history.py tests/test_runtime_api_chat_jobs.py tests/test_webchat_conversations.py Co-authored-by: Codex --- docs/management-api.md | 1 + docs/openapi.md | 5 +- src/Undefined/api/_openapi.py | 6 +- src/Undefined/api/app.py | 2 + src/Undefined/api/routes/chat.py | 195 +++++++++++++------------ src/Undefined/api/webchat_store.py | 14 +- tests/test_runtime_api_chat_history.py | 81 ++++++++++ tests/test_runtime_api_chat_jobs.py | 50 +++++++ tests/test_webchat_conversations.py | 67 +++++++++ 9 files changed, 320 insertions(+), 101 deletions(-) diff --git a/docs/management-api.md b/docs/management-api.md index 89355aca..3dac51cf 100644 --- a/docs/management-api.md +++ b/docs/management-api.md @@ -246,6 +246,7 @@ GET /api/v1/management/runtime/commands?scope=webui&q=help - `GET runtime/chat/jobs/active`:`conversation_id` 可选。 - 校验: - Runtime 会检查 `conversation_id` 是否存在。 + - `POST runtime/chat` 在 `stream=false` 时也会创建并等待 Runtime WebChat job,运行期间受同一套全局 job 互斥保护。 - 删除历史时,如果仍有运行中或收尾落盘中的 job,会透传 `409`。 - 响应: - `200` / `202`:聊天结果、历史页、job 快照或 active job。 diff --git a/docs/openapi.md b/docs/openapi.md index d6e60eb6..89242180 100644 --- a/docs/openapi.md +++ b/docs/openapi.md @@ -256,8 +256,8 @@ curl http://127.0.0.1:8788/openapi.json } ``` -- `stream = false` 保持同步响应。 -- 当 `stream = true` 时,Runtime 会创建 WebChat job。旧接口仍可返回 SSE,但 WebUI 默认使用 job 查询接口续接事件。 +- `stream = false` 返回同步 JSON,但后端同样会创建 WebChat job 并等待其完成;运行期间会占用 WebChat 全局 job 互斥,删除会话、清空历史和启动其他 WebChat job 会返回 `409`。 +- 当 `stream = true` 时,Runtime 会创建 WebChat job 并通过旧接口返回 SSE;WebUI 默认使用 job 查询接口续接事件。 - `conversation_id` 可选;不传时使用兼容默认会话 `legacy-system-42`,传入不存在的会话 ID 时返回 `404`。 #### Event types @@ -424,6 +424,7 @@ curl http://127.0.0.1:8788/openapi.json ### WebUI AI Chat Jobs - `POST /api/v1/chat/jobs`:创建后台 job,Body 为 `{"message":"...","conversation_id":"..."}`,`conversation_id` 可选。 +- WebChat job 在当前 Runtime 进程内全局单飞;如果已有任意 WebChat job 正在运行或收尾落盘,创建新 job 返回 `409`。兼容的非流式 `POST /api/v1/chat` 也走同一套 job 互斥,只是等待完成后返回同步结果。 - WebChat 前端粘贴或选择的附件会先被合并进 `message`:小图片使用 `CQ:image,file=base64://...`,普通文件使用 WebUI 管理代理的 `/api/runtime/chat/files` 缓存后生成 `CQ:file,id=...`;Runtime 侧沿用 `register_message_attachments()` 注册到 `webui` 附件作用域。 - WebChat 前端引用 AI 消息、选中文本或 HTML 预览中点选的元素时,不新增后端端点,也不写入单独附件;发送前会把待引用内容转换成 Markdown blockquote 并拼接到 `message` 前面,例如 `> 引用 AI:` / `> 引用 HTML 片段:`。后端只接收最终 `message` 字符串。 - `GET /api/v1/chat/jobs/active?conversation_id=`:返回当前运行中的 WebChat job(没有则为 `null`)。不传时返回任意当前 WebChat job;传入时只在该 job 属于对应会话时返回。 diff --git a/src/Undefined/api/_openapi.py b/src/Undefined/api/_openapi.py index 4e127d44..e5ce5f20 100644 --- a/src/Undefined/api/_openapi.py +++ b/src/Undefined/api/_openapi.py @@ -109,8 +109,10 @@ def _build_openapi_spec(ctx: RuntimeAPIContext, request: web.Request) -> dict[st "summary": "WebUI special private chat", "description": ( "POST JSON {message, stream?, conversation_id?}. " - "stream=false runs synchronously; stream=true creates a " - "WebChat job and streams lifecycle events as SSE." + "stream=false waits for an internal WebChat job and " + "returns JSON; stream=true uses the same WebChat job " + "lifecycle and streams events as SSE. WebChat jobs are " + "process-local single-flight while running or finalizing." ), } }, diff --git a/src/Undefined/api/app.py b/src/Undefined/api/app.py index 17e9fe13..7d5c9190 100644 --- a/src/Undefined/api/app.py +++ b/src/Undefined/api/app.py @@ -69,6 +69,8 @@ async def start(self) -> None: logger.info("[RuntimeAPI] 已启动: %s", cfg.api.display_url) async def stop(self) -> None: + await self._chat_job_manager.stop() + for task in self._background_tasks: task.cancel() if self._background_tasks: diff --git a/src/Undefined/api/routes/chat.py b/src/Undefined/api/routes/chat.py index bd5e1757..5ec041a1 100644 --- a/src/Undefined/api/routes/chat.py +++ b/src/Undefined/api/routes/chat.py @@ -339,6 +339,25 @@ async def create_job( job.task = asyncio.create_task(self._run_job(job), name=f"webchat:{job.job_id}") return job + async def stop(self) -> None: + async with self._lock: + jobs = list(self._jobs.values()) + for job in jobs: + if self._job_blocks_history_mutation(job): + await self.cancel_job(job.job_id) + tasks: list[asyncio.Task[None]] = [] + for job in jobs: + task = job.task + if task is not None and not task.done(): + tasks.append(task) + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + for job in jobs: + if not job.done.is_set(): + with suppress(asyncio.TimeoutError): + await asyncio.wait_for(job.done.wait(), timeout=5.0) + await self.conversation_store.stop() + async def get_job(self, job_id: str) -> ChatJob | None: async with self._lock: return self._jobs.get(job_id) @@ -411,27 +430,19 @@ async def cancel_job(self, job_id: str) -> ChatJob | None: job.conversation_id, job.status, ) - job.status = "cancelled" - self._mark_job_finished(job) + async with job.changed: + job.status = "cancelled" + self._mark_job_finished(job) + job.changed.notify_all() if job.task is not None and not job.task.done(): job.task.cancel() self._schedule_cancel_finalizer(job) - if not any( - event.event == "error" and event.payload.get("error") == "cancelled" - for event in job.events - ): - await self._append_event( - job, - "error", - { - "error": "cancelled", - "job_id": job.job_id, - "duration_ms": job.duration_ms, - }, - ) + await self._append_cancelled_event_once(job) if job.task is None or job.task.done(): - job.history_finalized = True - job.done.set() + async with job.changed: + job.history_finalized = True + job.done.set() + job.changed.notify_all() return job def _schedule_cancel_finalizer(self, job: ChatJob) -> None: @@ -459,7 +470,9 @@ async def _complete_cancelled_job(self, job: ChatJob) -> None: ) job.history_finalized = True finally: - job.done.set() + async with job.changed: + job.done.set() + job.changed.notify_all() async def events_after(self, job: ChatJob, after: int) -> list[ChatJobEvent]: async with job.changed: @@ -669,19 +682,7 @@ async def _webchat_event_callback(event: str, payload: dict[str, Any]) -> None: job.conversation_id, job.duration_ms, ) - if not any( - event.event == "error" and event.payload.get("error") == "cancelled" - for event in job.events - ): - await self._append_event( - job, - "error", - { - "error": "cancelled", - "job_id": job.job_id, - "duration_ms": job.duration_ms, - }, - ) + await self._append_cancelled_event_once(job) except Exception as exc: logger.exception("[RuntimeAPI] chat job failed: %s", exc) job.status = "error" @@ -705,7 +706,9 @@ async def _webchat_event_callback(event: str, payload: dict[str, Any]) -> None: "[RuntimeAPI] chat job history finalize failed: %s", exc ) job.history_finalized = True - job.done.set() + async with job.changed: + job.done.set() + job.changed.notify_all() async def _append_event( self, job: ChatJob, event: str, payload: dict[str, Any] @@ -713,6 +716,23 @@ async def _append_event( async with job.changed: return self._append_event_locked(job, event, payload) + async def _append_cancelled_event_once(self, job: ChatJob) -> None: + async with job.changed: + if any( + event.event == "error" and event.payload.get("error") == "cancelled" + for event in job.events + ): + return + self._append_event_locked( + job, + "error", + { + "error": "cancelled", + "job_id": job.job_id, + "duration_ms": job.duration_ms, + }, + ) + def _append_event_locked( self, job: ChatJob, event: str, payload: dict[str, Any] ) -> ChatJobEvent: @@ -836,31 +856,35 @@ async def maybe_schedule_title_generation(self, conversation_id: str) -> None: len(answer), ) - async def _run_title() -> None: - try: - title = await generate_webchat_title(self._ctx.ai, question, answer) - if title: - await self.conversation_store.apply_generated_title( - conversation_id, - title=title, - basis_hash=basis_hash, - ) - logger.info( - "[RuntimeAPI][WebChat] 标题生成完成: conversation_id=%s title_len=%s", - conversation_id, - len(title), + async def _run_title() -> None: + try: + title = await generate_webchat_title(self._ctx.ai, question, answer) + if title: + await self.conversation_store.apply_generated_title( + conversation_id, + title=title, + basis_hash=basis_hash, + ) + logger.info( + "[RuntimeAPI][WebChat] 标题生成完成: conversation_id=%s title_len=%s", + conversation_id, + len(title), + ) + return + except asyncio.CancelledError: + raise + except Exception as exc: + logger.warning( + "[RuntimeAPI] webchat title generation failed: %s", exc ) - return - except asyncio.CancelledError: - raise - except Exception as exc: - logger.warning("[RuntimeAPI] webchat title generation failed: %s", exc) - await self.conversation_store.mark_title_failed(conversation_id, basis_hash) + await self.conversation_store.mark_title_failed( + conversation_id, basis_hash + ) - task = asyncio.create_task( - _run_title(), name=f"webchat-title:{conversation_id}" - ) - self.conversation_store.register_title_task(conversation_id, task) + task = asyncio.create_task( + _run_title(), name=f"webchat-title:{conversation_id}" + ) + self.conversation_store.register_title_task(conversation_id, task) def _job_elapsed_ms(job: ChatJob, now: float | None = None) -> int: @@ -2249,50 +2273,31 @@ async def chat_handler( len(text), ) if not stream: - outputs: list[str] = [] - webui_scope_key = build_attachment_scope( - user_id=_VIRTUAL_USER_ID, - request_type="private", - webui_session=True, - ) - - async def _capture_private_message(user_id: int, message: str) -> None: - _ = user_id - content = str(message or "").strip() - if not content: - return - rendered = await render_message_with_pic_placeholders( - content, - registry=ctx.ai.attachment_registry, - scope_key=webui_scope_key, - strict=False, - ) - if not rendered.delivery_text.strip(): - return - outputs.append(rendered.delivery_text) - await job_manager.conversation_store.append_message( - conversation_id, - role="bot", - text_content=rendered.history_text, - display_name="Bot", - user_name="Bot", - attachments=rendered.attachments, - ) - try: - mode = await run_webui_chat( - ctx, - text=text, - send_output=_capture_private_message, - conversation_store=job_manager.conversation_store, - conversation_id=conversation_id, - ) - await job_manager.maybe_schedule_title_generation(conversation_id) - except Exception as exc: - logger.exception("[RuntimeAPI] chat failed: %s", exc) + job = await job_manager.create_job(text, conversation_id) + except KeyError: + return _json_error("Conversation not found", status=404) + except RuntimeError: + return _json_error("Chat job is still running", status=409) + try: + await job.done.wait() + except asyncio.CancelledError: + await job_manager.cancel_job(job.job_id) + raise + snapshot = await job_manager.snapshot(job) + if job.status == "cancelled": + return _json_error("Chat cancelled", status=409) + if job.status == "error": + logger.error("[RuntimeAPI] chat failed: %s", job.error) return _json_error("Chat failed", status=502) + outputs = [ + str(item) for item in snapshot.get("messages", []) if str(item).strip() + ] + mode = str(snapshot.get("mode") or job.mode or "chat") payload = _build_chat_response_payload(mode, outputs) payload["conversation_id"] = conversation_id + payload["job_id"] = job.job_id + payload["duration_ms"] = job.duration_ms logger.info( "[RuntimeAPI][WebChat] 非流式聊天完成: conversation_id=%s mode=%s outputs=%s", conversation_id, diff --git a/src/Undefined/api/webchat_store.py b/src/Undefined/api/webchat_store.py index 5f483840..f9504c27 100644 --- a/src/Undefined/api/webchat_store.py +++ b/src/Undefined/api/webchat_store.py @@ -107,6 +107,14 @@ def __init__(self) -> None: def adapter(self, conversation_id: str) -> WebChatHistoryAdapter: return WebChatHistoryAdapter(self, conversation_id) + async def stop(self) -> None: + tasks = [task for task in self._title_tasks.values() if not task.done()] + self._title_tasks.clear() + for task in tasks: + task.cancel() + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + async def ensure_ready(self, legacy_history_manager: Any | None = None) -> None: async with self._global_lock: if not self._loaded: @@ -425,11 +433,12 @@ async def mark_title_failed(self, conversation_id: str, basis_hash: str) -> None def register_title_task( self, conversation_id: str, task: asyncio.Task[None] - ) -> None: + ) -> bool: conv_id = _normalize_conversation_id(conversation_id) previous = self._title_tasks.get(conv_id) if previous is not None and not previous.done(): - return + task.cancel() + return False self._title_tasks[conv_id] = task def _cleanup(done_task: asyncio.Task[None]) -> None: @@ -437,6 +446,7 @@ def _cleanup(done_task: asyncio.Task[None]) -> None: self._title_tasks.pop(conv_id, None) task.add_done_callback(_cleanup) + return True def title_task_running(self, conversation_id: str) -> bool: task = self._title_tasks.get(_normalize_conversation_id(conversation_id)) diff --git a/tests/test_runtime_api_chat_history.py b/tests/test_runtime_api_chat_history.py index 67836230..88ba537b 100644 --- a/tests/test_runtime_api_chat_history.py +++ b/tests/test_runtime_api_chat_history.py @@ -485,6 +485,87 @@ async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str assert payload["error"] == "Chat job is still running" +@pytest.mark.asyncio +async def test_runtime_chat_non_stream_blocks_history_clear_until_done( + monkeypatch: pytest.MonkeyPatch, +) -> None: + started = asyncio.Event() + release = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, *, text: str, send_output: Any) -> str: + assert text == "hello" + started.set() + await release.wait() + await send_output(42, "done") + return "chat" + + async def _fake_render_message_with_pic_placeholders( + message: str, + *, + registry: Any, + scope_key: str, + strict: bool, + ) -> Any: + _ = registry, scope_key, strict + return SimpleNamespace( + delivery_text=message, + history_text=message, + attachments=[], + ) + + history = _DummyHistoryManager() + context = RuntimeAPIContext( + config_getter=lambda: SimpleNamespace( + api=SimpleNamespace( + enabled=True, + host="127.0.0.1", + port=8788, + auth_key="changeme", + openapi_enabled=True, + ), + superadmin_qq=10001, + bot_qq=20002, + ), + onebot=SimpleNamespace(connection_status=lambda: {}), + ai=SimpleNamespace( + attachment_registry=object(), + memory_storage=SimpleNamespace(count=lambda: 0), + ), + command_dispatcher=SimpleNamespace(), + queue_manager=SimpleNamespace(snapshot=lambda: {}), + history_manager=history, + ) + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + monkeypatch.setattr( + runtime_api_chat, + "render_message_with_pic_placeholders", + _fake_render_message_with_pic_placeholders, + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + chat_request = cast( + web.Request, + cast(Any, _JsonRequest(query={}, _json={"message": "hello"})), + ) + chat_task = asyncio.create_task(server._chat_handler(chat_request)) + await asyncio.wait_for(started.wait(), timeout=1) + + clear_response = await server._chat_history_clear_handler( + cast(web.Request, cast(Any, SimpleNamespace(query={}))) + ) + clear_payload = json.loads(clear_response.text or "{}") + + assert clear_response.status == 409 + assert clear_payload["error"] == "Chat job is still running" + + release.set() + chat_response = await asyncio.wait_for(chat_task, timeout=1) + chat_payload = json.loads(cast(web.Response, chat_response).text or "{}") + + assert chat_response.status == 200 + assert chat_payload["reply"] == "done" + assert chat_payload["messages"] == ["done"] + + @pytest.mark.asyncio async def test_runtime_chat_history_clear_returns_409_until_history_finalized() -> None: history = _DummyHistoryManager() diff --git a/tests/test_runtime_api_chat_jobs.py b/tests/test_runtime_api_chat_jobs.py index caf3a148..546d3da9 100644 --- a/tests/test_runtime_api_chat_jobs.py +++ b/tests/test_runtime_api_chat_jobs.py @@ -305,6 +305,56 @@ async def test_chat_job_cancel_unknown_returns_404() -> None: assert payload["error"] == "Job not found" +@pytest.mark.asyncio +async def test_chat_job_cancelled_error_event_is_appended_once() -> None: + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + manager = server._chat_job_manager + job = runtime_api_chat.ChatJob( + job_id="job-cancel", + text="hello", + created_at=0.0, + updated_at=0.0, + ) + + await asyncio.gather(*(manager._append_cancelled_event_once(job) for _ in range(8))) + + cancelled_events = [ + event + for event in job.events + if event.event == "error" and event.payload.get("error") == "cancelled" + ] + assert len(cancelled_events) == 1 + + +@pytest.mark.asyncio +async def test_runtime_api_stop_cancels_running_webchat_job( + monkeypatch: pytest.MonkeyPatch, +) -> None: + started = asyncio.Event() + cancelled = asyncio.Event() + + async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: + started.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + cancelled.set() + raise + return "chat" + + monkeypatch.setattr(runtime_api_chat, "run_webui_chat", _fake_run_webui_chat) + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + job = await server._chat_job_manager.create_job("hello") + await asyncio.wait_for(started.wait(), timeout=1) + + await server.stop() + + assert cancelled.is_set() + assert job.status == "cancelled" + assert job.done.is_set() + assert job.history_finalized is True + + @pytest.mark.asyncio async def test_chat_job_events_refreshes_stage_without_advancing_seq( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_webchat_conversations.py b/tests/test_webchat_conversations.py index 5de8052e..5d1d96fc 100644 --- a/tests/test_webchat_conversations.py +++ b/tests/test_webchat_conversations.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import logging from pathlib import Path @@ -231,6 +232,72 @@ async def _fake_generate_title(_ai: Any, question: str, answer: str) -> str: assert updated["title_status"] == "generated" +@pytest.mark.asyncio +async def test_webchat_title_generation_concurrent_schedule_starts_once( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + context = _context(history=SimpleNamespace(get_recent_private=lambda *_args: [])) + started = asyncio.Event() + release = asyncio.Event() + calls = 0 + + async def _fake_generate_title(_ai: Any, question: str, answer: str) -> str: + nonlocal calls + assert question == "并发问题" + assert answer == "并发回答" + calls += 1 + started.set() + await release.wait() + return "并发标题" + + monkeypatch.setattr( + runtime_api_chat, "generate_webchat_title", _fake_generate_title + ) + server = RuntimeAPIServer(context, host="127.0.0.1", port=8788) + create_response = await server._chat_conversation_create_handler( + cast(web.Request, cast(Any, _JsonRequest(query={}, _json={}))) + ) + conversation_id = str( + json.loads(create_response.text or "{}")["conversation"]["id"] + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="user", + text_content="并发问题", + display_name="system", + user_name="system", + ) + await server._chat_job_manager.conversation_store.append_message( + conversation_id, + role="bot", + text_content="并发回答", + display_name="Bot", + user_name="Bot", + ) + + await asyncio.gather( + server._chat_job_manager.maybe_schedule_title_generation(conversation_id), + server._chat_job_manager.maybe_schedule_title_generation(conversation_id), + ) + await asyncio.wait_for(started.wait(), timeout=1) + assert calls == 1 + assert len(server._chat_job_manager.conversation_store._title_tasks) == 1 + + release.set() + task = server._chat_job_manager.conversation_store._title_tasks[conversation_id] + await task + updated = await server._chat_job_manager.conversation_store.get_conversation( + conversation_id + ) + + assert updated is not None + assert updated["title"] == "并发标题" + assert updated["title_status"] == "generated" + assert calls == 1 + + @pytest.mark.asyncio async def test_webchat_title_generation_uses_chat_model_not_summary_model() -> None: chat_config = SimpleNamespace(model_name="chat-model") From 9bdff13dac1ccf0178e870982d5fccc722c51f82 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Sat, 6 Jun 2026 22:05:31 +0800 Subject: [PATCH 5/5] fix(api): bound webchat shutdown waits - add a configurable WebChat shutdown task timeout - recancel pending job tasks after shutdown wait timeout - cover cancellation-resistant task shutdown behavior Tests: - uv run pytest tests/test_runtime_api_chat_jobs.py - uv run ruff check src/Undefined/api/routes/chat.py tests/test_runtime_api_chat_jobs.py Co-authored-by: Codex --- src/Undefined/api/routes/chat.py | 17 ++++++++++++- tests/test_runtime_api_chat_jobs.py | 39 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/Undefined/api/routes/chat.py b/src/Undefined/api/routes/chat.py index 5ec041a1..3b8c7cbd 100644 --- a/src/Undefined/api/routes/chat.py +++ b/src/Undefined/api/routes/chat.py @@ -54,6 +54,7 @@ _CHAT_SSE_KEEPALIVE_SECONDS = 10.0 _CHAT_STAGE_REFRESH_SECONDS = 1.0 _CHAT_JOB_EVENT_BUFFER_LIMIT = 1000 +SHUTDOWN_TASK_TIMEOUT = 5.0 _PREVIEW_LIMIT = 800 _WEBCHAT_SEND_MESSAGE_TOOLS = frozenset( { @@ -351,7 +352,21 @@ async def stop(self) -> None: if task is not None and not task.done(): tasks.append(task) if tasks: - await asyncio.gather(*tasks, return_exceptions=True) + task_wait = asyncio.gather(*tasks, return_exceptions=True) + try: + await asyncio.wait_for( + asyncio.shield(task_wait), + timeout=SHUTDOWN_TASK_TIMEOUT, + ) + except asyncio.TimeoutError: + logger.warning( + "[RuntimeAPI][WebChat] stop 等待 job task 超时,重新取消未完成任务: tasks=%s", + len(tasks), + ) + for task in tasks: + if not task.done(): + task.cancel() + await task_wait for job in jobs: if not job.done.is_set(): with suppress(asyncio.TimeoutError): diff --git a/tests/test_runtime_api_chat_jobs.py b/tests/test_runtime_api_chat_jobs.py index 546d3da9..02476dec 100644 --- a/tests/test_runtime_api_chat_jobs.py +++ b/tests/test_runtime_api_chat_jobs.py @@ -355,6 +355,45 @@ async def _fake_run_webui_chat(_ctx: Any, **_kwargs: Any) -> str: assert job.history_finalized is True +@pytest.mark.asyncio +async def test_runtime_api_stop_recancels_shutdown_task_after_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(runtime_api_chat, "SHUTDOWN_TASK_TIMEOUT", 0.01) + started = asyncio.Event() + cancel_count = 0 + + async def _resist_first_cancel() -> None: + nonlocal cancel_count + started.set() + while True: + try: + await asyncio.sleep(3600) + except asyncio.CancelledError: + cancel_count += 1 + if cancel_count >= 2: + raise + + server = RuntimeAPIServer(_context(), host="127.0.0.1", port=8788) + job = runtime_api_chat.ChatJob( + job_id="job-resist-cancel", + text="hello", + created_at=0.0, + updated_at=0.0, + status="running", + ) + job.task = asyncio.create_task(_resist_first_cancel()) + async with server._chat_job_manager._lock: + server._chat_job_manager._jobs[job.job_id] = job + await asyncio.wait_for(started.wait(), timeout=1) + + await asyncio.wait_for(server.stop(), timeout=1) + + assert cancel_count == 2 + assert job.status == "cancelled" + assert job.done.is_set() + + @pytest.mark.asyncio async def test_chat_job_events_refreshes_stage_without_advancing_seq( monkeypatch: pytest.MonkeyPatch,