diff --git a/_conf_schema.json b/_conf_schema.json index 01003142..ca2e719b 100644 --- a/_conf_schema.json +++ b/_conf_schema.json @@ -102,8 +102,8 @@ "current_persona_name": { "description": "当前目标人格", "type": "string", - "hint": "插件将学习并优化此人格的对话风格。需要在人格设置中存在", - "default": "default" + "hint": "插件将学习并优化此人格的对话风格。留空或填 default 表示跟随 AstrBot 当前人格;填写具体 ID 时需在人格设置中存在", + "default": "" } } }, diff --git a/config.py b/config.py index 0ba4a815..16f0de7a 100644 --- a/config.py +++ b/config.py @@ -112,7 +112,7 @@ class PluginConfig(BaseModel): disable_local_reply_when_delegated: bool = True # 检测到 Group Chat Plus 时禁用本地回复器 # 当前人格设置 - current_persona_name: str = "default" + current_persona_name: str = "" # 学习参数 learning_interval_hours: int = 6 # 学习间隔(小时) @@ -353,7 +353,7 @@ def create_from_config(cls, config: dict, data_dir: Optional[str] = None) -> 'Pl target_qq_list=target_settings.get('target_qq_list', []), target_blacklist=target_settings.get('target_blacklist', []), - current_persona_name=target_settings.get('current_persona_name', 'default'), + current_persona_name=target_settings.get('current_persona_name', ''), filter_provider_id=model_configuration.get('filter_provider_id', None), refine_provider_id=model_configuration.get('refine_provider_id', None), diff --git a/core/page_api.py b/core/page_api.py index 2fe6dd41..3e4f6820 100644 --- a/core/page_api.py +++ b/core/page_api.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio +import json import os import sys import time @@ -212,6 +213,9 @@ async def post_style_action(self) -> dict[str, Any]: self._body_int(body, "id") ) return self._operation(success, message) + if action == "update": + success, message, item = await self._update_style_review(body) + return self._operation(success, message, item=item) return self._operation(False, f"未知表达学习操作: {action or '(empty)'}") except Exception as exc: logger.error(f"[PluginPageAPI] style action failed: {exc}", exc_info=True) @@ -1159,6 +1163,62 @@ async def _delete_content_item(self, bucket: str, item_id: int) -> tuple[bool, s logger.error(f"[PluginPageAPI] delete content failed: {exc}", exc_info=True) return False, str(exc) + async def _update_style_review(self, body: Mapping[str, Any]) -> tuple[bool, str, dict[str, Any]]: + review_id = self._body_int(body, "id") + updates: dict[str, Any] = {} + + if "description" in body: + updates["description"] = str(body.get("description") or "") + if "few_shots_content" in body: + updates["few_shots_content"] = str(body.get("few_shots_content") or "") + if "learned_patterns" in body: + patterns = body.get("learned_patterns") + updates["learned_patterns"] = ( + json.dumps(patterns, ensure_ascii=False) + if isinstance(patterns, (list, dict)) + else str(patterns or "") + ) + + if not updates: + return False, "没有可保存的表达方式字段", {} + + database_manager = getattr(self._container(), "database_manager", None) + if not database_manager or not hasattr(database_manager, "get_session"): + return False, "数据库管理器未初始化", {} + + try: + from sqlalchemy import select + + try: + from ..models.orm import StyleLearningReview + except ImportError: + from models.orm import StyleLearningReview + + async with database_manager.get_session() as session: + result = await session.execute( + select(StyleLearningReview).where(StyleLearningReview.id == review_id) + ) + review = result.scalar_one_or_none() + if not review: + return False, f"表达方式审查 {review_id} 不存在", {} + for key, value in updates.items(): + setattr(review, key, value) + if hasattr(review, "updated_at"): + review.updated_at = datetime.now() + item = { + "id": review.id, + "description": review.description, + "few_shots_content": review.few_shots_content, + "learned_patterns": review.learned_patterns, + "status": review.status, + "group_id": review.group_id, + } + await session.commit() + return True, "表达方式已更新", item + except Exception as exc: + logger.error(f"[PluginPageAPI] update style review failed: {exc}", exc_info=True) + return False, str(exc), {} + async def _delete_learning_batch(self, batch_id: int) -> tuple[bool, str]: return await self._delete_content_item("history", batch_id) diff --git a/pages/dashboard/app.js b/pages/dashboard/app.js index f8c3c4c6..975a1b3d 100644 --- a/pages/dashboard/app.js +++ b/pages/dashboard/app.js @@ -40,6 +40,7 @@ height: 0, canvasBound: false, }, + toastTimer: null, }; const physics = { @@ -188,13 +189,32 @@ function showToast(message, tone = "ok") { const region = $("toast-region"); if (!region) return; + if (state.toastTimer) { + clearTimeout(state.toastTimer); + state.toastTimer = null; + } + region.replaceChildren(); const el = document.createElement("div"); el.className = `toast ${tone}`; - el.textContent = message; + const text = document.createElement("span"); + text.textContent = message; + const close = document.createElement("button"); + close.className = "toast-close"; + close.type = "button"; + close.setAttribute("aria-label", "关闭提示"); + close.textContent = "×"; + close.addEventListener("click", () => { + if (state.toastTimer) clearTimeout(state.toastTimer); + el.remove(); + }); + el.append(text, close); region.appendChild(el); - setTimeout(() => { + state.toastTimer = setTimeout(() => { el.classList.add("leaving"); - setTimeout(() => el.remove(), 220); + setTimeout(() => { + el.remove(); + if (state.toastTimer) state.toastTimer = null; + }, 220); }, 3200); } @@ -212,14 +232,24 @@ const modal = $("detail-modal"); setText("modal-title", title); setHtml("modal-body", html); - if (modal && typeof modal.showModal === "function") { - modal.showModal(); + if (!modal) return; + if (modal.open && typeof modal.close === "function") { + modal.close(); + } + if (typeof modal.showModal === "function") { + try { + modal.showModal(); + return; + } catch (_) {} } + modal.setAttribute("open", ""); } function closeModal() { const modal = $("detail-modal"); - if (modal && typeof modal.close === "function") modal.close(); + if (!modal) return; + if (typeof modal.close === "function") modal.close(); + else modal.removeAttribute("open"); } function resolvePageFromHash() { @@ -527,6 +557,7 @@ renderGenericBarChart("style-pattern-chart", chartItems); const reviews = ((data.reviews || {}).reviews || []); setHtml("expression-review-list", reviews.map((item) => styleReviewHtml(item)).join("") || empty("暂无表达审查")); + state.pageData.lastStyleItems = reviews; } function renderReviews(data) { @@ -541,6 +572,7 @@ setHtml("persona-review-list", personaPending.map((item) => personaReviewHtml(item)).join("") || empty("暂无人格更新")); setHtml("style-review-list", styleReviews.map((item) => styleReviewHtml(item)).join("") || empty("暂无表达审查")); setHtml("jargon-review-list", pendingJargon.map((item) => jargonReviewHtml(item)).join("") || empty("暂无黑话候选")); + state.pageData.lastStyleItems = styleReviews; setHtml("reviewed-persona-list", personaReviewed.slice(0, 12).map((item) => `
${escapeHtml(item.id)} @@ -576,6 +608,7 @@

${escapeHtml(item.few_shots_content || item.learned_patterns || "").slice(0, 220)}

+ ${button("编辑", `data-style-action="edit" data-id="${escapeAttr(item.id)}"`)} ${button("详情", `data-review-action="detail" data-kind="style" data-id="${escapeAttr(item.id)}"`)} ${button("批准", `data-review-action="approve" data-kind="style" data-id="${escapeAttr(item.id)}"`, "solid-button")} ${button("拒绝", `data-review-action="reject" data-kind="style" data-id="${escapeAttr(item.id)}"`)} @@ -616,11 +649,13 @@ ${escapeHtml(id)} ${escapeHtml(item.name || id)}
+ ${button("编辑", `data-persona-action="edit" data-persona-id="${escapeAttr(id)}"`)} ${button("导出", `data-persona-action="export" data-persona-id="${escapeAttr(id)}"`)} ${button("删除", `data-persona-action="delete" data-persona-id="${escapeAttr(id)}"`, "danger-button")}
`; }).join("") || empty("暂无人格列表")); + state.pageData.lastPersonaItems = personas; const backups = ((data.backups || {}).backups || []); setText("persona-backup-count", fmt(backups.length, 0)); @@ -951,8 +986,50 @@ await loadPageData(state.page, { force: true }); } + function modalFieldValue(id) { + return $(id)?.value ?? ""; + } + + function parseModalJson(id, fallback) { + const raw = modalFieldValue(id).trim(); + if (!raw) return fallback; + try { + return JSON.parse(raw); + } catch (_) { + return raw.split(/\n+/).map((line) => line.trim()).filter(Boolean); + } + } + + async function handleStyleAction(action, id) { + if (action === "edit") { + const item = (state.pageData.lastStyleItems || []).find((entry) => String(entry.id) === String(id)) || {}; + const patterns = typeof item.learned_patterns === "string" + ? item.learned_patterns + : JSON.stringify(item.learned_patterns || [], null, 2); + showModal("编辑表达方式", ` + + + + + `); + } + } + async function handlePersonaAction(buttonEl) { const action = buttonEl.dataset.personaAction; + if (action === "edit") { + const personaId = buttonEl.dataset.personaId; + const item = (state.pageData.lastPersonaItems || []).find((entry) => String(entry.persona_id || entry.id || entry.name) === String(personaId)) || {}; + const beginDialogs = JSON.stringify(item.begin_dialogs || [], null, 2); + showModal("编辑人格", ` + + + + + + `); + return; + } const body = { action, id: buttonEl.dataset.id, @@ -1064,12 +1141,13 @@ }); document.addEventListener("click", async (event) => { - const target = event.target.closest("[data-route-card],[data-refresh-page],[data-review-action],[data-jargon-action],[data-persona-action],[data-content-action],[data-settings-group]"); + const target = event.target.closest("[data-route-card],[data-refresh-page],[data-review-action],[data-jargon-action],[data-style-action],[data-persona-action],[data-content-action],[data-settings-group]"); if (!target) return; if (target.dataset.routeCard) navigateToPage(target.dataset.routeCard); if (target.dataset.refreshPage) loadPageData(target.dataset.refreshPage, { force: true }); if (target.dataset.reviewAction) await handleReviewAction(target.dataset.kind, target.dataset.id, target.dataset.reviewAction); if (target.dataset.jargonAction) await handleJargonAction(target.dataset.jargonAction, target.dataset.id); + if (target.dataset.styleAction) await handleStyleAction(target.dataset.styleAction, target.dataset.id); if (target.dataset.personaAction) await handlePersonaAction(target); if (target.dataset.contentAction) await handleContentAction(target); if (target.dataset.settingsGroup) { @@ -1099,6 +1177,45 @@ await loadPageData("jargon-learning", { force: true }); }); + document.addEventListener("click", async (event) => { + const save = event.target.closest("#modal-style-save"); + if (!save) return; + const result = await apiPost("style/action", { + action: "update", + id: save.dataset.id, + description: modalFieldValue("modal-style-description"), + few_shots_content: modalFieldValue("modal-style-few-shots"), + learned_patterns: parseModalJson("modal-style-patterns", []), + }); + closeModal(); + showToast(result.message || "表达方式已更新", result.success ? "ok" : "error"); + state.pageData.style = null; + state.pageData.lastStyleItems = []; + await loadPageData("expression-learning", { force: true }); + }); + + document.addEventListener("click", async (event) => { + const save = event.target.closest("#modal-persona-save"); + if (!save) return; + const personaId = save.dataset.personaId; + const result = await apiPost("persona/action", { + action: "update", + persona_id: personaId, + persona: { + persona_id: personaId, + name: modalFieldValue("modal-persona-name"), + system_prompt: modalFieldValue("modal-persona-prompt"), + prompt: modalFieldValue("modal-persona-prompt"), + begin_dialogs: parseModalJson("modal-persona-dialogs", []), + }, + }); + closeModal(); + showToast(result.message || "人格已更新", result.success ? "ok" : "error"); + state.pageData.persona = null; + state.pageData.lastPersonaItems = []; + await loadPageData("persona-learning", { force: true }); + }); + qsa(".nav-item").forEach((item) => { item.addEventListener("click", (event) => { event.preventDefault(); diff --git a/pages/dashboard/styles.css b/pages/dashboard/styles.css index 4670009d..6124232a 100644 --- a/pages/dashboard/styles.css +++ b/pages/dashboard/styles.css @@ -997,6 +997,10 @@ textarea { } .toast { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; max-width: min(360px, calc(100vw - 36px)); padding: 10px 12px; border: 1px solid var(--border); @@ -1007,6 +1011,21 @@ textarea { animation: toastIn 180ms var(--spring); } +.toast-close { + width: 24px; + height: 24px; + padding: 0; + border: 0; + color: var(--muted); + background: transparent; + line-height: 1; + cursor: pointer; +} + +.toast-close:hover { + color: var(--text); +} + .toast.error { color: #991b1b; } diff --git a/tests/integration/test_webui_static_assets.py b/tests/integration/test_webui_static_assets.py index a7b6a9e6..3e57252b 100644 --- a/tests/integration/test_webui_static_assets.py +++ b/tests/integration/test_webui_static_assets.py @@ -86,7 +86,17 @@ def test_embedded_plugin_page_uses_astrbot_bridge_and_module_dashboard(): assert 'apiGet("persona"' in script assert 'apiGet("graphs"' in script assert 'apiPost("reviews/action"' in script + assert 'apiPost("style/action"' in script + assert 'apiPost("persona/action"' in script assert 'apiPost("settings/action"' in script + assert 'data-jargon-action="edit"' in script + assert 'data-style-action="edit"' in script + assert 'data-persona-action="edit"' in script + assert 'id="modal-jargon-save"' in script + assert 'id="modal-style-save"' in script + assert 'id="modal-persona-save"' in script + assert "region.replaceChildren()" in script + assert "toast-close" in script assert 'return `page/${String(path || "")' in script assert "initSpringMotion" in script assert "startGraphRender" in script diff --git a/tests/unit/test_persona_selection.py b/tests/unit/test_persona_selection.py index 8c0cff50..e8d2b870 100644 --- a/tests/unit/test_persona_selection.py +++ b/tests/unit/test_persona_selection.py @@ -1,9 +1,14 @@ from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest -from utils.persona_selection import get_persona_identifier, resolve_target_persona +from utils.persona_selection import ( + _RECENT_WARNINGS, + get_configured_persona_id, + get_persona_identifier, + resolve_target_persona, +) @pytest.mark.asyncio @@ -53,6 +58,33 @@ async def test_resolve_target_persona_falls_back_to_single_existing_when_default assert persona["persona_id"] == "suleng" assert persona["selection_source"] == "single_existing" + manager.get_persona.assert_not_awaited() + + +def test_default_persona_config_means_follow_astrbot_current_persona(): + assert get_configured_persona_id(SimpleNamespace(current_persona_name="default")) is None + assert get_configured_persona_id(SimpleNamespace(current_persona_name="")) is None + + +@pytest.mark.asyncio +async def test_missing_current_persona_warning_is_throttled(): + _RECENT_WARNINGS.clear() + manager = AsyncMock() + manager.get_default_persona_v3.return_value = { + "persona_id": "default", + "name": "default", + "prompt": "Default prompt", + } + manager.get_persona.return_value = None + manager.get_all_personas.return_value = [] + log = SimpleNamespace(warning=Mock()) + + config = SimpleNamespace(current_persona_name="") + + await resolve_target_persona(manager, config, "aiocqhttp:group:1", require_existing=True, log=log) + await resolve_target_persona(manager, config, "aiocqhttp:group:1", require_existing=True, log=log) + + assert log.warning.call_count == 1 def test_get_persona_identifier_uses_persona_id_before_display_name(): diff --git a/utils/persona_selection.py b/utils/persona_selection.py index ee0ba4d4..634e4e24 100644 --- a/utils/persona_selection.py +++ b/utils/persona_selection.py @@ -1,8 +1,13 @@ """Helpers for resolving the AstrBot persona targeted by this plugin.""" import inspect +import time from typing import Any, Dict, Optional +_WARNING_TTL_SECONDS = 300.0 +_RECENT_WARNINGS: Dict[str, float] = {} + + def _is_unset_mock(obj: Any, name: str) -> bool: return ( obj is not None @@ -43,7 +48,7 @@ def get_configured_persona_id(config: Any) -> Optional[str]: if value is None: return None persona_id = str(value).strip() - if not persona_id or persona_id == "[%None]": + if not persona_id or persona_id == "[%None]" or persona_id.lower() == "default": return None return persona_id @@ -97,6 +102,18 @@ def _warn(log: Any, message: str) -> None: log.warning(message) +def _warn_once(log: Any, message: str, *, key: Optional[str] = None) -> None: + if not log or not hasattr(log, "warning"): + return + cache_key = key or message + now = time.monotonic() + last = _RECENT_WARNINGS.get(cache_key) + if last is not None and now - last < _WARNING_TTL_SECONDS: + return + _RECENT_WARNINGS[cache_key] = now + log.warning(message) + + async def _read_persona_by_id( persona_manager: Any, persona_id: str, @@ -108,14 +125,18 @@ async def _read_persona_by_id( try: persona = await _maybe_await(get_persona(persona_id)) except Exception as exc: - _warn(log, f"读取人格 {persona_id} 失败: {exc}") + _warn_once(log, f"读取人格 {persona_id} 失败: {exc}", key=f"read:{persona_id}:{exc}") return None normalized = normalize_persona_data(persona) if normalized: returned_id = normalized.get("persona_id") if returned_id and str(returned_id) != persona_id: - _warn(log, f"读取人格 {persona_id} 返回了 {returned_id},按未命中处理") + _warn_once( + log, + f"读取人格 {persona_id} 返回了 {returned_id},按未命中处理", + key=f"mismatch:{persona_id}:{returned_id}", + ) return None normalized["persona_id"] = returned_id or persona_id normalized.setdefault("name", normalized["persona_id"]) @@ -164,7 +185,11 @@ async def resolve_target_persona_from_web( if configured: configured["selection_source"] = "plugin_config" return configured - _warn(log, f"插件配置的人格 {configured_id} 不存在,将尝试 AstrBot 当前人格") + _warn_once( + log, + f"插件配置的人格 {configured_id} 不存在,将尝试 AstrBot 当前人格", + key=f"web-config-missing:{configured_id}", + ) get_persona_for_group = _explicit_attr(persona_web_manager, "get_persona_for_group") get_all_personas = _explicit_attr(persona_web_manager, "get_all_personas_for_web") @@ -182,7 +207,11 @@ async def resolve_target_persona_from_web( if existing: existing["selection_source"] = "astrbot_default" return existing - _warn(log, f"AstrBot 当前人格 {normalized['persona_id']} 不存在于 PersonaManager") + _warn_once( + log, + f"AstrBot 当前人格 {normalized['persona_id']} 不存在于 PersonaManager", + key=f"web-current-missing:{normalized['persona_id']}", + ) else: normalized["selection_source"] = "astrbot_default" return normalized @@ -199,7 +228,11 @@ async def resolve_target_persona_from_web( if existing: existing["selection_source"] = "astrbot_default" return existing - _warn(log, f"AstrBot 全局人格 {normalized['persona_id']} 不存在于 PersonaManager") + _warn_once( + log, + f"AstrBot 全局人格 {normalized['persona_id']} 不存在于 PersonaManager", + key=f"web-global-missing:{normalized['persona_id']}", + ) else: normalized["selection_source"] = "astrbot_default" return normalized @@ -212,7 +245,11 @@ async def resolve_target_persona_from_web( if persona and persona.get("persona_id") ] if len(normalized) == 1: - _warn(log, f"目标人格不可用,回退到唯一可用人格 {normalized[0]['persona_id']}") + _warn_once( + log, + f"目标人格不可用,回退到唯一可用人格 {normalized[0]['persona_id']}", + key=f"web-single-fallback:{normalized[0]['persona_id']}", + ) normalized[0]["selection_source"] = "single_existing" return normalized[0] @@ -229,7 +266,7 @@ async def _read_single_existing_persona( try: personas = await _maybe_await(get_all_personas()) except Exception as exc: - _warn(log, f"读取人格列表失败: {exc}") + _warn_once(log, f"读取人格列表失败: {exc}", key=f"list:{exc}") return None normalized = [ @@ -238,7 +275,11 @@ async def _read_single_existing_persona( if persona and persona.get("persona_id") ] if len(normalized) == 1: - _warn(log, f"目标人格不可用,回退到唯一可用人格 {normalized[0]['persona_id']}") + _warn_once( + log, + f"目标人格不可用,回退到唯一可用人格 {normalized[0]['persona_id']}", + key=f"single-fallback:{normalized[0]['persona_id']}", + ) return normalized[0] return None @@ -260,19 +301,24 @@ async def resolve_target_persona( return None configured_id = get_configured_persona_id(config) + placeholder_persona: Optional[Dict[str, Any]] = None if configured_id: configured = await _read_persona_by_id(persona_manager, configured_id, log) if configured: configured["selection_source"] = "plugin_config" return configured - _warn(log, f"插件配置的人格 {configured_id} 不存在,将尝试 AstrBot 当前人格") + _warn_once( + log, + f"插件配置的人格 {configured_id} 不存在,将尝试 AstrBot 当前人格", + key=f"config-missing:{configured_id}", + ) get_default_persona = _explicit_attr(persona_manager, "get_default_persona_v3") if get_default_persona: try: default_persona = await _maybe_await(get_default_persona(umo)) except Exception as exc: - _warn(log, f"读取 AstrBot 当前人格失败: {exc}") + _warn_once(log, f"读取 AstrBot 当前人格失败: {exc}", key=f"current:{umo}:{exc}") else: normalized = normalize_persona_data(default_persona) if normalized: @@ -285,17 +331,18 @@ async def resolve_target_persona( normalized["selection_source"] = "astrbot_default" return normalized if existing_id: - existing = await _read_persona_by_id(persona_manager, existing_id, log) - if existing: - normalized["selection_source"] = "astrbot_default" - return normalized - _warn(log, f"AstrBot 当前人格 {existing_id} 不存在于 PersonaManager") + _warn_once( + log, + "AstrBot 当前人格 default 可能是占位值,将尝试回退到可用人格", + key="current-placeholder-default", + ) + placeholder_persona = normalized if umo != "default": try: default_persona = await _maybe_await(get_default_persona("default")) except Exception as exc: - _warn(log, f"读取 AstrBot default 人格失败: {exc}") + _warn_once(log, f"读取 AstrBot default 人格失败: {exc}", key=f"default:{exc}") else: normalized = normalize_persona_data(default_persona) if normalized: @@ -308,16 +355,23 @@ async def resolve_target_persona( normalized["selection_source"] = "astrbot_default" return normalized if existing_id: - existing = await _read_persona_by_id(persona_manager, existing_id, log) - if existing: - normalized["selection_source"] = "astrbot_default" - return normalized - _warn(log, f"AstrBot default 人格 {existing_id} 不存在于 PersonaManager") + _warn_once( + log, + "AstrBot default 人格可能是占位值,将尝试回退到可用人格", + key="current-placeholder-default", + ) + placeholder_persona = placeholder_persona or normalized if require_existing: single_persona = await _read_single_existing_persona(persona_manager, log) if single_persona: + if placeholder_persona and str(single_persona.get("persona_id") or "").lower() == "default": + placeholder_persona["selection_source"] = "astrbot_default_placeholder" + return placeholder_persona single_persona["selection_source"] = "single_existing" return single_persona + if placeholder_persona: + placeholder_persona["selection_source"] = "astrbot_default_placeholder" + return placeholder_persona return None