From a13442895dbb3d292dff6eebb41dfaa87ef719f9 Mon Sep 17 00:00:00 2001 From: 3aKHP <2971755027@qq.com> Date: Wed, 27 May 2026 09:25:06 +0800 Subject: [PATCH 1/6] feat(web-admin): expand operational controls - Add Web Admin action queue for bot-owned runtime operations - Add diagnostics controls for reloads, context cleanup, and health checks - Add awakening management, immediate summary/briefing, and tieba peek actions --- frontend/src/api/awakening.ts | 30 ++ frontend/src/api/groups.ts | 11 + frontend/src/api/llmRuntime.ts | 39 ++ frontend/src/api/tieba.ts | 4 + frontend/src/config/nav.ts | 2 + frontend/src/views/AwakeningView.vue | 428 ++++++++++++++++++ frontend/src/views/DiagnosticsView.vue | 294 +++++++++++- frontend/src/views/GroupsView.vue | 29 +- frontend/src/views/TiebaView.vue | 17 +- quickquip/adapters/nonebot/lifecycle.py | 8 + .../adapters/nonebot/web_admin_actions.py | 174 +++++++ quickquip/app/web/action_queue.py | 179 ++++++++ quickquip/app/web/app.py | 4 +- quickquip/app/web/routes/awakening.py | 311 +++++++++++++ quickquip/app/web/routes/config.py | 5 + quickquip/app/web/routes/groups.py | 50 +- quickquip/app/web/routes/llm_runtime.py | 116 +++++ quickquip/app/web/routes/tieba.py | 16 + quickquip/common/paths.py | 1 + quickquip/llm/service_parts/health.py | 36 +- tests/unit/web/test_action_queue.py | 23 + tests/unit/web/test_awakening_routes.py | 90 ++++ 22 files changed, 1859 insertions(+), 8 deletions(-) create mode 100644 frontend/src/api/awakening.ts create mode 100644 frontend/src/api/llmRuntime.ts create mode 100644 frontend/src/views/AwakeningView.vue create mode 100644 quickquip/adapters/nonebot/web_admin_actions.py create mode 100644 quickquip/app/web/action_queue.py create mode 100644 quickquip/app/web/routes/awakening.py create mode 100644 quickquip/app/web/routes/llm_runtime.py create mode 100644 tests/unit/web/test_action_queue.py create mode 100644 tests/unit/web/test_awakening_routes.py diff --git a/frontend/src/api/awakening.ts b/frontend/src/api/awakening.ts new file mode 100644 index 0000000..281fd41 --- /dev/null +++ b/frontend/src/api/awakening.ts @@ -0,0 +1,30 @@ +import { request } from './index' + +export async function fetchAwakening() { + return request('/api/awakening') +} + +export async function fetchAwakeningGroup(groupId: string) { + return request(`/api/awakening/${encodeURIComponent(groupId)}`) +} + +export async function setAwakeningRule(groupId: string, ruleName: string, enabled: boolean) { + return request(`/api/awakening/${encodeURIComponent(groupId)}/rules/${encodeURIComponent(ruleName)}`, { + method: 'POST', + body: JSON.stringify({ enabled }), + }) +} + +export async function setAwakeningBoredom(groupId: string, enabled: boolean) { + return request(`/api/awakening/${encodeURIComponent(groupId)}/boredom`, { + method: 'POST', + body: JSON.stringify({ enabled }), + }) +} + +export async function updateAwakeningSettings(groupId: string, payload: Record) { + return request(`/api/awakening/${encodeURIComponent(groupId)}/settings`, { + method: 'PUT', + body: JSON.stringify(payload), + }) +} diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts index d8698e0..083bc98 100644 --- a/frontend/src/api/groups.ts +++ b/frontend/src/api/groups.ts @@ -14,3 +14,14 @@ export async function updateGroup(type: string, gid: string | number, enabled: b body: JSON.stringify({ enabled }), }) } + +export async function runSummaryNow(gid: string | number) { + return request(`/api/groups/summary/${encodeURIComponent(String(gid))}/now`, { method: 'POST' }) +} + +export async function runBriefingNow(gid: string | number, period?: string) { + return request(`/api/groups/briefing/${encodeURIComponent(String(gid))}/now`, { + method: 'POST', + body: JSON.stringify({ period: period || null }), + }) +} diff --git a/frontend/src/api/llmRuntime.ts b/frontend/src/api/llmRuntime.ts new file mode 100644 index 0000000..0ae9c94 --- /dev/null +++ b/frontend/src/api/llmRuntime.ts @@ -0,0 +1,39 @@ +import { request } from './index' + +export async function fetchLlmHealth(verbose = false) { + return request(`/api/llm-runtime/health?verbose=${verbose ? 'true' : 'false'}`) +} + +export async function reloadLlmRuntime() { + return request('/api/llm-runtime/reload', { method: 'POST' }) +} + +export async function reloadMcpRuntime() { + return request('/api/llm-runtime/mcp/reload', { method: 'POST' }) +} + +export async function reloadPersonas() { + return request('/api/llm-runtime/personas/reload', { method: 'POST' }) +} + +export async function reloadRules() { + return request('/api/llm-runtime/rules/reload', { method: 'POST' }) +} + +export async function clearLlmContext(scopeKey: string) { + return request('/api/llm-runtime/context/clear', { + method: 'POST', + body: JSON.stringify({ scope_key: scopeKey }), + }) +} + +export async function deleteLlmContextMessage(scopeKey: string, messageId: string) { + return request('/api/llm-runtime/context/delete-message', { + method: 'POST', + body: JSON.stringify({ scope_key: scopeKey, message_id: messageId }), + }) +} + +export async function fetchLlmRuntimeActions(limit = 20) { + return request(`/api/llm-runtime/actions?limit=${encodeURIComponent(String(limit))}`) +} diff --git a/frontend/src/api/tieba.ts b/frontend/src/api/tieba.ts index 3bd4100..41747b7 100644 --- a/frontend/src/api/tieba.ts +++ b/frontend/src/api/tieba.ts @@ -38,3 +38,7 @@ export async function fetchTiebaThreads(forum: string, { keyword, limit, offset export async function fetchTiebaThread(forum: string, tid: number) { return request(`/api/tieba/threads/${encodeURIComponent(forum)}/${encodeURIComponent(tid)}`) } + +export async function peekTiebaThread(forum: string) { + return request(`/api/tieba/peek?forum=${encodeURIComponent(forum)}`) +} diff --git a/frontend/src/config/nav.ts b/frontend/src/config/nav.ts index 3cfdc8b..7f9bef9 100644 --- a/frontend/src/config/nav.ts +++ b/frontend/src/config/nav.ts @@ -9,6 +9,7 @@ import ConversationsView from '../views/ConversationsView.vue' import PersonasView from '../views/PersonasView.vue' import LlmAboutView from '../views/LlmAboutView.vue' import GroupSettingsView from '../views/GroupSettingsView.vue' +import AwakeningView from '../views/AwakeningView.vue' import RateLimitView from '../views/RateLimitView.vue' import TiebaView from '../views/TiebaView.vue' import QuotesView from '../views/QuotesView.vue' @@ -55,6 +56,7 @@ export const NAV_ITEMS: NavItem[] = [ { key: 'rules', path: '/rules', label: '规则', icon: 'ToggleLeft', section: 'ops', component: RulesView }, { key: 'groups', path: '/groups', label: '群组', icon: 'Users', section: 'ops', component: GroupsView }, { key: 'group-settings', path: '/group-settings', label: '群 LLM', icon: 'SlidersHorizontal', section: 'ops', component: GroupSettingsView }, + { key: 'awakening', path: '/awakening', label: '唤醒', icon: 'BellRing', section: 'ops', component: AwakeningView }, { key: 'rate-limit', path: '/rate-limit', label: '限流', icon: 'Gauge', section: 'ops', component: RateLimitView }, { key: 'memory', path: '/memory', label: '记忆', icon: 'Brain', section: 'llm', component: MemoryView }, { key: 'conversations', path: '/conversations', label: '对话', icon: 'MessageCircle', section: 'llm', component: ConversationsView }, diff --git a/frontend/src/views/AwakeningView.vue b/frontend/src/views/AwakeningView.vue new file mode 100644 index 0000000..9a68baf --- /dev/null +++ b/frontend/src/views/AwakeningView.vue @@ -0,0 +1,428 @@ + + + + + diff --git a/frontend/src/views/DiagnosticsView.vue b/frontend/src/views/DiagnosticsView.vue index c5cdde6..19706b4 100644 --- a/frontend/src/views/DiagnosticsView.vue +++ b/frontend/src/views/DiagnosticsView.vue @@ -3,6 +3,60 @@
+ +
+ +
+

运行时操作

+

重载配置、人格、聊天规则和 MCP,并执行一次轻量健康检查。

+
+
+ +
+ 健康检查 + 详细健康 + 重载 LLM + 重载 MCP + 重载人格 + 重载规则 +
+ +
+ + 清空上下文 + + 删除消息 +
+ +
{{ runtimeError }}
+
{{ healthText }}
+ +
+
+

最近动作

+ 刷新 +
+ +
+
+
+ {{ actionLabel(item.action_type) }} + {{ statusLabel(item.status) }} + {{ formatActionTime(item.updated_at || item.created_at) }} +
+
{{ item.error }}
+
{{ prettyJson(item.result) }}
+
+
+
+
+
@@ -139,9 +193,12 @@ import { computed, onMounted, ref } from 'vue' import UiPageHeader from '../components/ui/UiPageHeader.vue' import UiButton from '../components/ui/UiButton.vue' import UiCard from '../components/ui/UiCard.vue' +import UiEmpty from '../components/ui/UiEmpty.vue' import UiIcon from '../components/ui/UiIcon.vue' import UiTag from '../components/ui/UiTag.vue' import { fetchProviders, runRegression, runSampleRequest } from '../api/diagnostics' +import { clearLlmContext, deleteLlmContextMessage, fetchLlmHealth, fetchLlmRuntimeActions, reloadLlmRuntime, reloadMcpRuntime, reloadPersonas, reloadRules } from '../api/llmRuntime' +import { toast } from '../toast' const providers = ref([]) const sampleLoading = ref(false) @@ -151,6 +208,15 @@ const regressionLoading = ref(false) const regressionError = ref(null) const regressionResults = ref([]) const regressionInput = ref('') +const healthLoading = ref(false) +const healthLoadingVerbose = ref(false) +const healthText = ref('') +const runtimeError = ref(null) +const runtimeLoading = ref('') +const contextScope = ref('') +const contextMessageId = ref('') +const actionsLoading = ref(false) +const actions = ref([]) interface SampleRequest { provider_id: string @@ -185,6 +251,57 @@ function prettyJson(obj: any): string { } } +function actionLabel(actionType: string): string { + return ({ + llm_reload: '重载 LLM', + mcp_reload: '重载 MCP', + personas_reload: '重载人格', + rules_reload: '重载规则', + clear_context: '清空上下文', + delete_context_message: '删除上下文消息', + summary_now: '立即总结', + briefing_now: '立即播报', + } as Record)[actionType] || actionType +} + +function statusLabel(status: string): string { + return ({ + queued: '等待', + running: '执行中', + succeeded: '成功', + failed: '失败', + } as Record)[status] || status +} + +function actionVariant(status: string): string { + return ({ + queued: 'info', + running: 'warn', + succeeded: 'success', + failed: 'danger', + } as Record)[status] || 'info' +} + +function formatActionTime(raw: string): string { + if (!raw) return '' + const d = new Date(raw) + if (Number.isNaN(d.getTime())) return raw + const pad = (n: number) => String(n).padStart(2, '0') + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +} + +async function loadActions() { + actionsLoading.value = true + try { + const data = await fetchLlmRuntimeActions(20) + actions.value = data.actions || [] + } catch (e: unknown) { + runtimeError.value = (e as Error).message + } finally { + actionsLoading.value = false + } +} + async function loadProviders() { try { const data = await fetchProviders() @@ -199,6 +316,82 @@ async function loadProviders() { } } +async function loadHealth(verbose: boolean) { + if (verbose) healthLoadingVerbose.value = true + else healthLoading.value = true + runtimeError.value = null + try { + const data = await fetchLlmHealth(verbose) + healthText.value = data.text || '' + } catch (e: unknown) { + runtimeError.value = (e as Error).message + } finally { + healthLoading.value = false + healthLoadingVerbose.value = false + } +} + +async function runRuntimeAction(action: string) { + runtimeLoading.value = action + runtimeError.value = null + try { + let data: any + if (action === 'llm') data = await reloadLlmRuntime() + else if (action === 'mcp') data = await reloadMcpRuntime() + else if (action === 'personas') data = await reloadPersonas() + else data = await reloadRules() + if (data?.load_error || data?.error) { + runtimeError.value = data.load_error || data.error + toast('操作完成但存在错误', 'error') + } else { + toast(data?.queued ? '操作已入队' : '操作已完成') + } + if (data?.queued) await loadActions() + if (data?.status) healthText.value = data.status + if (data?.summary) healthText.value = JSON.stringify(data.summary, null, 2) + } catch (e: unknown) { + runtimeError.value = (e as Error).message + toast('操作失败', 'error') + } finally { + runtimeLoading.value = '' + } +} + +async function clearContext() { + if (!contextScope.value.trim()) return + if (!confirm(`清空 ${contextScope.value.trim()} 的 LLM 短期上下文?`)) return + runtimeLoading.value = 'clear' + runtimeError.value = null + try { + const data = await clearLlmContext(contextScope.value.trim()) + toast(data?.queued ? '清空上下文已入队' : `已删除 ${data.deleted || 0} 条`) + if (data?.queued) await loadActions() + } catch (e: unknown) { + runtimeError.value = (e as Error).message + toast('清空失败', 'error') + } finally { + runtimeLoading.value = '' + } +} + +async function deleteContextMessage() { + const scope = contextScope.value.trim() + const messageId = contextMessageId.value.trim() + if (!scope || !messageId) return + runtimeLoading.value = 'delete-msg' + runtimeError.value = null + try { + const data = await deleteLlmContextMessage(scope, messageId) + toast(data?.queued ? '删除消息已入队' : (data.deleted ? '已删除消息' : '未找到消息')) + if (data?.queued) await loadActions() + } catch (e: unknown) { + runtimeError.value = (e as Error).message + toast('删除失败', 'error') + } finally { + runtimeLoading.value = '' + } +} + async function sendSample() { sampleLoading.value = true sampleError.value = null @@ -247,6 +440,7 @@ async function runRegress() { onMounted(() => { loadProviders() + loadActions() }) @@ -311,6 +505,92 @@ onMounted(() => { gap: var(--qq-gap-sm); } +.runtime-actions, +.context-tools { + display: flex; + align-items: center; + gap: var(--qq-gap-sm); + flex-wrap: wrap; +} + +.action-panel { + display: flex; + flex-direction: column; + gap: var(--qq-gap-sm); + border-top: 1px solid var(--qq-border); + padding-top: var(--qq-gap-sm); +} + +.action-panel__head, +.action-main { + display: flex; + align-items: center; + gap: var(--qq-gap-sm); + flex-wrap: wrap; +} + +.action-panel__head { + justify-content: space-between; +} + +.action-panel__head h4 { + margin: 0; + color: var(--qq-text); + font-size: var(--qq-text-sm); +} + +.action-list { + display: flex; + flex-direction: column; + gap: var(--qq-gap-xs); +} + +.action-item { + padding: var(--qq-gap-xs) var(--qq-gap-sm); + border: 1px solid var(--qq-border); + border-radius: var(--qq-radius-sm); + background: var(--qq-surface-strong); +} + +.action-main { + justify-content: space-between; +} + +.action-time, +.mono { + color: var(--qq-text-muted); + font-family: var(--qq-font-mono); + font-size: var(--qq-text-xs); +} + +.action-error { + margin-top: 4px; + color: var(--qq-danger); + font-size: var(--qq-text-xs); + word-break: break-word; +} + +.action-result { + margin: 4px 0 0; + color: var(--qq-text-muted); + font-family: var(--qq-font-mono); + font-size: var(--qq-text-xs); + white-space: pre-wrap; + word-break: break-word; +} + +.inline-field { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--qq-text-muted); + font-size: var(--qq-text-xs); +} + +.inline-field input { + width: 190px; +} + .form-row, .form-actions, .result-meta, @@ -382,7 +662,8 @@ textarea { } .result-text, -.json-block { +.json-block, +.health-block { border: 1px solid var(--qq-border); border-radius: var(--qq-radius-sm); background: var(--qq-surface-strong); @@ -408,6 +689,17 @@ textarea { word-break: break-word; } +.health-block { + margin: 0; + padding: var(--qq-gap-sm); + color: var(--qq-text); + font-family: var(--qq-font-mono); + font-size: var(--qq-text-xs); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + .data-detail { color: var(--qq-text-muted); font-size: var(--qq-text-xs); diff --git a/frontend/src/views/GroupsView.vue b/frontend/src/views/GroupsView.vue index 251439d..549e9bc 100644 --- a/frontend/src/views/GroupsView.vue +++ b/frontend/src/views/GroupsView.vue @@ -7,7 +7,13 @@

每日总结

    -
  • {{ gid }}移除
  • +
  • + {{ gid }} + + 立即生成 + 移除 + +
添加
@@ -15,7 +21,19 @@

每日简报

    -
  • {{ gid }}移除
  • +
  • + {{ gid }} + + + 立即生成 + 移除 + +
添加
@@ -29,15 +47,18 @@ import { onMounted, ref } from 'vue' import UiPageHeader from '../components/ui/UiPageHeader.vue'; import UiCard from '../components/ui/UiCard.vue' import UiButton from '../components/ui/UiButton.vue'; import UiIcon from '../components/ui/UiIcon.vue' import UiLoading from '../components/ui/UiLoading.vue'; import UiEmpty from '../components/ui/UiEmpty.vue' -import { fetchGroups, fetchKnownGroups, updateGroup } from '../api/groups'; import { toast } from '../toast' +import { fetchGroups, fetchKnownGroups, runBriefingNow, runSummaryNow, updateGroup } from '../api/groups'; import { toast } from '../toast' const loaded = ref(false); const error = ref(null); const groups = ref<{ summary: string[]; briefing: string[] }>({ summary: [], briefing: [] }); const knownGroups = ref([]) const newSummaryId = ref(''); const newSummaryIdManual = ref(''); const newBriefingId = ref(''); const newBriefingIdManual = ref('') +const runningNow = ref(''); const briefingPeriods = ref>({}) onMounted(async () => { try { const [g, k] = await Promise.all([fetchGroups(), fetchKnownGroups()]); groups.value = g; knownGroups.value = k.groups || []; loaded.value = true } catch (e: unknown) { error.value = (e as Error).message } }) function availableGroups(type: 'summary' | 'briefing'): string[] { return knownGroups.value.filter(g => !groups.value[type].includes(g)) } async function addGroup(type: 'summary' | 'briefing') { const v = (type === 'summary' ? (newSummaryId.value || newSummaryIdManual.value) : (newBriefingId.value || newBriefingIdManual.value)).trim(); if (!v || !/^\d+$/.test(v)) { toast('群号必须为纯数字', 'error'); return }; try { await updateGroup(type, v, true); if (!groups.value[type].includes(v)) groups.value[type].push(v); if (type === 'summary') { newSummaryId.value = ''; newSummaryIdManual.value = '' } else { newBriefingId.value = ''; newBriefingIdManual.value = '' }; toast(`群 ${v} 已添加`) } catch (e: unknown) { toast(`操作失败:${(e as Error).message}`, 'error') } } async function removeGroup(type: 'summary' | 'briefing', gid: string) { try { await updateGroup(type, gid, false); groups.value[type] = groups.value[type].filter(g => g !== gid); toast(`群 ${gid} 已移除`) } catch (e: unknown) { toast(`操作失败:${(e as Error).message}`, 'error') } } +async function summaryNow(gid: string) { runningNow.value = `summary:${gid}`; try { const r = await runSummaryNow(gid); toast(`总结任务已入队:${r.action?.id || ''}`) } catch (e: unknown) { toast(`入队失败:${(e as Error).message}`, 'error', 4000) } finally { runningNow.value = '' } } +async function briefingNow(gid: string) { runningNow.value = `briefing:${gid}`; try { const r = await runBriefingNow(gid, briefingPeriods.value[gid] || undefined); toast(`播报任务已入队:${r.action?.id || ''}`) } catch (e: unknown) { toast(`入队失败:${(e as Error).message}`, 'error', 4000) } finally { runningNow.value = '' } } diff --git a/frontend/src/views/TiebaView.vue b/frontend/src/views/TiebaView.vue index fdc47b6..c15b540 100644 --- a/frontend/src/views/TiebaView.vue +++ b/frontend/src/views/TiebaView.vue @@ -4,6 +4,7 @@ @@ -117,7 +118,7 @@ import UiTag from '../components/ui/UiTag.vue' import UiIcon from '../components/ui/UiIcon.vue' import UiLoading from '../components/ui/UiLoading.vue' import UiEmpty from '../components/ui/UiEmpty.vue' -import { listTiebaForums, fetchTiebaThreads, fetchTiebaThread, tiebaImgProxyUrl, openTiebaSyncStream } from '../api/tieba' +import { listTiebaForums, fetchTiebaThreads, fetchTiebaThread, tiebaImgProxyUrl, openTiebaSyncStream, peekTiebaThread } from '../api/tieba' import { toast } from '../toast' const PAGE_SIZE = 30 @@ -129,6 +130,7 @@ const loadError = ref(null) const syncing = ref(false) const syncLog = ref([]) const logEl = ref(null) +const peeking = ref(false) function startSync(forum: string | null) { syncing.value = true @@ -251,6 +253,19 @@ async function openDetail(tid: number) { } } +async function peekSelected() { + if (!selectedForum.value) return + peeking.value = true + try { + detail.value = await peekTiebaThread(selectedForum.value) + toast('现爬完成') + } catch (e: unknown) { + toast((e as Error).message, 'error', 4000) + } finally { + peeking.value = false + } +} + loadAll() diff --git a/quickquip/adapters/nonebot/lifecycle.py b/quickquip/adapters/nonebot/lifecycle.py index 0cadbba..7f4ced7 100644 --- a/quickquip/adapters/nonebot/lifecycle.py +++ b/quickquip/adapters/nonebot/lifecycle.py @@ -13,6 +13,7 @@ daily_briefing_enabled_groups, ) from quickquip.adapters.nonebot.awakening_plugin import boredom_enabled_groups +from quickquip.adapters.nonebot.web_admin_actions import process_web_admin_actions from quickquip.tieba.service import tieba_service logger = logging.getLogger(__name__) @@ -93,5 +94,12 @@ async def _save_on_shutdown(): id="web_admin_state_sync", replace_existing=True, ) + scheduler.add_job( + process_web_admin_actions, + "interval", + seconds=5, + id="web_admin_action_queue", + replace_existing=True, + ) except ModuleNotFoundError: pass diff --git a/quickquip/adapters/nonebot/web_admin_actions.py b/quickquip/adapters/nonebot/web_admin_actions.py new file mode 100644 index 0000000..9a9ab9e --- /dev/null +++ b/quickquip/adapters/nonebot/web_admin_actions.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import re +from datetime import datetime, timedelta +from typing import Any + +from quickquip.app.message_pipeline import ( + _ensure_llm_bindings, + daily_briefing_enabled_groups, + daily_enabled_groups, + get_llm_service, + reload_chat_rules_pipeline, + rule_switch, +) +from quickquip.app.web.action_queue import WebAdminAction, action_queue +from quickquip.chat.daily_briefing import normalize_period + +_SCOPE_KEY_RE = re.compile(r"^(?:\d{5,12}|private:\d{5,15})$") + + +def _chat_type(scope_key: str) -> str: + return "private" if scope_key.startswith("private:") else "group" + + +def _chat_id(scope_key: str) -> str: + return scope_key.removeprefix("private:") + + +def _validate_scope(scope_key: Any) -> str: + key = str(scope_key or "").strip() + if not _SCOPE_KEY_RE.match(key): + raise ValueError("scope_key must be 5-12 digits or 'private:USER_ID'") + return key + + +def _get_bot(): + import nonebot + + return nonebot.get_bot() + + +async def _execute_runtime_action(action: WebAdminAction) -> dict[str, Any]: + _ensure_llm_bindings() + svc = get_llm_service() + + if action.action_type == "llm_reload": + config = await svc.reload_runtime(background=True) + return {"ok": not bool(config.load_error), "load_error": config.load_error} + + if action.action_type == "mcp_reload": + await svc.reload_mcp(background=False) + return {"ok": True, "status": svc.format_mcp_status()} + + if action.action_type == "personas_reload": + count, error = svc.reload_personas() + return {"ok": error is None, "count": count, "error": error} + + if action.action_type == "rules_reload": + summary = reload_chat_rules_pipeline() + return {"ok": True, "summary": summary} + + if action.action_type == "clear_context": + scope_key = _validate_scope(action.payload.get("scope_key")) + deleted = svc.clear_context(_chat_id(scope_key), chat_type=_chat_type(scope_key)) + return {"deleted": deleted} + + if action.action_type == "delete_context_message": + scope_key = _validate_scope(action.payload.get("scope_key")) + message_id = str(action.payload.get("message_id") or "").strip() + if not message_id: + raise ValueError("message_id is required") + deleted = svc.delete_message_from_context(scope_key, message_id) + return {"deleted": deleted} + + raise ValueError(f"unknown runtime action: {action.action_type}") + + +async def _execute_summary_now(action: WebAdminAction) -> dict[str, Any]: + group_id = str(action.payload.get("group_id") or "").strip() + if not group_id.isdigit(): + raise ValueError("group_id is required") + if not daily_enabled_groups.contains(group_id): + raise RuntimeError("daily summary is not enabled for this group") + + from quickquip.adapters.nonebot.daily_summary_plugin import ( + _LOCAL_TZ, + _mark_triggered, + _on_cooldown, + _run_generation, + _send_long_message, + ) + + if _on_cooldown(group_id): + raise RuntimeError("summary generation is on cooldown") + + bot = _get_bot() + _mark_triggered(group_id) + + now = datetime.now(tz=_LOCAL_TZ) + yesterday = now.date() - timedelta(days=1) + start_dt = now.replace( + year=yesterday.year, + month=yesterday.month, + day=yesterday.day, + hour=6, + minute=0, + second=0, + microsecond=0, + ) + date_label = ( + start_dt.strftime("%Y年%m月%d日 06:00") + + " 至 " + + now.strftime("%m月%d日 %H:%M") + ) + result = await _run_generation( + group_id, + start_dt.timestamp(), + now.timestamp(), + date_label, + ) + if result is None: + raise RuntimeError("summary generation skipped or failed") + + content, model_used = result + await _send_long_message(bot, int(group_id), content) + return {"model_used": model_used, "char_count": len(content)} + + +async def _execute_briefing_now(action: WebAdminAction) -> dict[str, Any]: + group_id = str(action.payload.get("group_id") or "").strip() + if not group_id.isdigit(): + raise ValueError("group_id is required") + if not daily_briefing_enabled_groups.contains(group_id) or not rule_switch.is_enabled(group_id, "daily_briefing"): + raise RuntimeError("daily briefing is not enabled for this group") + + from quickquip.adapters.nonebot.daily_briefing_plugin import ( + _LOCAL_TZ, + _default_period_for_now, + _mark_triggered, + _on_cooldown, + _render_briefing, + ) + + if _on_cooldown(group_id): + raise RuntimeError("briefing generation is on cooldown") + + period_raw = str(action.payload.get("period") or "").strip() + period = normalize_period(period_raw) if period_raw else None + if period is None: + period = _default_period_for_now(datetime.now(tz=_LOCAL_TZ)) + + bot = _get_bot() + _mark_triggered(group_id) + content, model_used = await _render_briefing(group_id, period) + await bot.send_group_msg(group_id=int(group_id), message=content) + return {"period": period, "model_used": model_used, "char_count": len(content)} + + +async def execute_web_admin_action(action: WebAdminAction) -> dict[str, Any]: + if action.action_type == "summary_now": + return await _execute_summary_now(action) + if action.action_type == "briefing_now": + return await _execute_briefing_now(action) + return await _execute_runtime_action(action) + + +async def process_web_admin_actions(limit: int = 5) -> None: + for action in action_queue.claim(limit): + try: + result = await execute_web_admin_action(action) + except Exception as exc: + action_queue.fail(action.id, str(exc)) + else: + action_queue.complete(action.id, result) diff --git a/quickquip/app/web/action_queue.py b/quickquip/app/web/action_queue.py new file mode 100644 index 0000000..3df92fd --- /dev/null +++ b/quickquip/app/web/action_queue.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import json +import sqlite3 +import uuid +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from quickquip.common.paths import WEB_ADMIN_ACTIONS_DB_PATH + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass(slots=True) +class WebAdminAction: + id: str + action_type: str + payload: dict[str, Any] + status: str + created_at: str + updated_at: str + result: dict[str, Any] | None = None + error: str = "" + + +class WebAdminActionQueue: + def __init__(self, path: str | Path = WEB_ADMIN_ACTIONS_DB_PATH): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self._ensure_schema() + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.path, timeout=10) + conn.row_factory = sqlite3.Row + return conn + + def _ensure_schema(self) -> None: + with self._connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS web_admin_actions ( + id TEXT PRIMARY KEY, + action_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + result_json TEXT, + error TEXT NOT NULL DEFAULT '' + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_web_admin_actions_status_created + ON web_admin_actions(status, created_at) + """ + ) + + @staticmethod + def _row_to_action(row: sqlite3.Row) -> WebAdminAction: + result_json = row["result_json"] + return WebAdminAction( + id=row["id"], + action_type=row["action_type"], + payload=json.loads(row["payload_json"] or "{}"), + status=row["status"], + created_at=row["created_at"], + updated_at=row["updated_at"], + result=json.loads(result_json) if result_json else None, + error=row["error"] or "", + ) + + def enqueue(self, action_type: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + action_id = uuid.uuid4().hex + now = _utc_now() + with self._connect() as conn: + conn.execute( + """ + INSERT INTO web_admin_actions + (id, action_type, payload_json, status, created_at, updated_at) + VALUES (?, ?, ?, 'queued', ?, ?) + """, + ( + action_id, + action_type, + json.dumps(payload or {}, ensure_ascii=False), + now, + now, + ), + ) + return {"id": action_id, "action_type": action_type, "status": "queued"} + + def claim(self, limit: int = 5) -> list[WebAdminAction]: + limit = max(1, min(int(limit), 20)) + now = _utc_now() + with self._connect() as conn: + conn.execute("BEGIN IMMEDIATE") + rows = conn.execute( + """ + SELECT * + FROM web_admin_actions + WHERE status = 'queued' + ORDER BY created_at ASC + LIMIT ? + """, + (limit,), + ).fetchall() + ids = [row["id"] for row in rows] + if ids: + conn.executemany( + """ + UPDATE web_admin_actions + SET status = 'running', updated_at = ? + WHERE id = ? AND status = 'queued' + """, + [(now, action_id) for action_id in ids], + ) + conn.commit() + return [ + WebAdminAction( + id=row["id"], + action_type=row["action_type"], + payload=json.loads(row["payload_json"] or "{}"), + status="running", + created_at=row["created_at"], + updated_at=now, + result=json.loads(row["result_json"]) if row["result_json"] else None, + error=row["error"] or "", + ) + for row in rows + ] + + def complete(self, action_id: str, result: dict[str, Any] | None = None) -> None: + with self._connect() as conn: + conn.execute( + """ + UPDATE web_admin_actions + SET status = 'succeeded', updated_at = ?, result_json = ?, error = '' + WHERE id = ? + """, + ( + _utc_now(), + json.dumps(result or {}, ensure_ascii=False), + action_id, + ), + ) + + def fail(self, action_id: str, error: str) -> None: + with self._connect() as conn: + conn.execute( + """ + UPDATE web_admin_actions + SET status = 'failed', updated_at = ?, error = ? + WHERE id = ? + """, + (_utc_now(), error[:2000], action_id), + ) + + def list_recent(self, limit: int = 20) -> list[dict[str, Any]]: + limit = max(1, min(int(limit), 100)) + with self._connect() as conn: + rows = conn.execute( + """ + SELECT * + FROM web_admin_actions + ORDER BY created_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [asdict(self._row_to_action(row)) for row in rows] + + +action_queue = WebAdminActionQueue() diff --git a/quickquip/app/web/app.py b/quickquip/app/web/app.py index 7137b48..f13b537 100644 --- a/quickquip/app/web/app.py +++ b/quickquip/app/web/app.py @@ -2,7 +2,7 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from quickquip.app.web import auth -from quickquip.app.web.routes import stats, rules, groups, config, logs, diagnostics, memory, summaries, personas, conversations, group_settings, rate_limit, tieba, wordcloud, llm_about, mcp_dashboard, cron_dashboard, audit, game_economy, niuniu, quotes, sensitive_filter +from quickquip.app.web.routes import stats, rules, groups, config, logs, diagnostics, memory, summaries, personas, conversations, group_settings, rate_limit, tieba, wordcloud, llm_about, mcp_dashboard, cron_dashboard, audit, game_economy, niuniu, quotes, sensitive_filter, awakening, llm_runtime from quickquip.app.web.settings import load_web_env _DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist" @@ -35,6 +35,8 @@ def create_app() -> FastAPI: app.include_router(niuniu.router, prefix="/ops/api", dependencies=auth.protected_dependencies) app.include_router(quotes.router, prefix="/ops/api", dependencies=auth.protected_dependencies) app.include_router(sensitive_filter.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router(awakening.router, prefix="/ops/api", dependencies=auth.protected_dependencies) + app.include_router(llm_runtime.router, prefix="/ops/api", dependencies=auth.protected_dependencies) if _DIST.exists(): app.mount("/ops", StaticFiles(directory=_DIST, html=True), name="static") diff --git a/quickquip/app/web/routes/awakening.py b/quickquip/app/web/routes/awakening.py new file mode 100644 index 0000000..c9c47b5 --- /dev/null +++ b/quickquip/app/web/routes/awakening.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import json +import re +from dataclasses import asdict, fields +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from filelock import FileLock +from pydantic import BaseModel +import tomllib + +from quickquip.app.message_pipeline import RULE_SWITCH_PATH, rule_switch, stats_tracker +from quickquip.app.web.action_queue import action_queue +from quickquip.app.web.audit import audit_logger +from quickquip.chat.awakening import ( + AwakeningConfig, + AwakeningGroupOverride, + CONFIG_AWAKENING_TOML, + get_config, + reload_config, +) + +router = APIRouter() + +_GROUP_ID_RE = re.compile(r"^\d{5,12}$") +_BOREDOM_GROUPS_PATH = Path("data/awakening_boredom_groups.json") +_CONFIG_PATH = CONFIG_AWAKENING_TOML +_AWAKENING_RULES = [ + ("awakening_extend", "唤醒延长"), + ("awakening_interest", "兴趣话题"), + ("awakening_relevance", "相关性唤醒"), + ("awakening_qa", "答疑唤醒"), + ("awakening_boredom", "无聊唤醒"), + ("awakening_fallback", "兜底概率"), +] +_VALID_RULES = {name for name, _label in _AWAKENING_RULES} +_OVERRIDE_FIELDS = [ + "extend_duration", + "fallback_probability", + "boredom_silence_seconds", + "boredom_probability", + "boredom_check_interval", + "boredom_dnd_start", + "boredom_dnd_end", + "relevance_threshold", + "qa_threshold", +] +_ALL_GROUP_OVERRIDE_FIELDS = [f.name for f in fields(AwakeningGroupOverride) if f.name != "group_id"] +_TIME_RE = re.compile(r"^(?:|(?:[01]\d|2[0-3]):[0-5]\d)$") + + +class ToggleBody(BaseModel): + enabled: bool + + +class AwakeningSettingsBody(BaseModel): + # Optional fields use model_dump(exclude_unset=True) in the route. + # Sending null clears a group override; omitting leaves it unchanged. + extend_duration: int | None = None + fallback_probability: float | None = None + boredom_silence_seconds: int | None = None + boredom_probability: float | None = None + boredom_check_interval: int | None = None + boredom_dnd_start: str | None = None + boredom_dnd_end: str | None = None + relevance_threshold: float | None = None + qa_threshold: float | None = None + + +def _validate_group_id(group_id: str) -> None: + if not _GROUP_ID_RE.match(group_id): + raise HTTPException(status_code=422, detail="group_id must be 5-12 digits") + + +def _validate_settings_payload(payload: dict[str, Any]) -> None: + for key, value in payload.items(): + if key not in _OVERRIDE_FIELDS: + raise HTTPException(status_code=422, detail=f"unsupported awakening setting: {key}") + if value is None: + continue + if key in {"extend_duration", "boredom_silence_seconds", "boredom_check_interval"}: + if type(value) is not int or value < 0 or value > 604800: + raise HTTPException(status_code=422, detail=f"{key} must be an integer between 0 and 604800") + elif key in {"fallback_probability", "boredom_probability", "relevance_threshold", "qa_threshold"}: + if type(value) not in {int, float} or value < 0 or value > 1: + raise HTTPException(status_code=422, detail=f"{key} must be between 0 and 1") + elif key in {"boredom_dnd_start", "boredom_dnd_end"}: + if not isinstance(value, str) or not _TIME_RE.match(value.strip()): + raise HTTPException(status_code=422, detail=f"{key} must be empty or HH:MM") + payload[key] = value.strip() + + +def _toml_quote(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + return f'"{escaped}"' + + +def _toml_value(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return repr(value) + if isinstance(value, str): + return _toml_quote(value) + if isinstance(value, list): + return "[" + ", ".join(_toml_quote(str(item)) for item in value) + "]" + raise TypeError(f"unsupported TOML value: {value!r}") + + +def _write_awakening_config(cfg: AwakeningConfig, path: Path | None = None) -> None: + target_path = path or _CONFIG_PATH + defaults = asdict(cfg.defaults) + lines = [ + "# Managed by QuickQuip Web Admin.", + "", + "[awakening.defaults]", + ] + for field_name in [ + "extend_duration", + "fallback_probability", + "boredom_silence_seconds", + "boredom_probability", + "boredom_check_interval", + "boredom_dnd_start", + "boredom_dnd_end", + "interest_topics", + "relevance_threshold", + "qa_threshold", + ]: + lines.append(f"{field_name} = {_toml_value(defaults[field_name])}") + + for group_id in sorted(cfg.group_overrides): + override = cfg.group_overrides[group_id] + lines.extend(["", "[[awakening.group_overrides]]", f"group_id = {_toml_quote(group_id)}"]) + values = asdict(override) + for field_name in _ALL_GROUP_OVERRIDE_FIELDS: + value = values[field_name] + if value is not None: + lines.append(f"{field_name} = {_toml_value(value)}") + + content = "\n".join(lines).rstrip() + "\n" + tomllib.loads(content) + target_path.parent.mkdir(parents=True, exist_ok=True) + tmp = target_path.with_suffix(target_path.suffix + ".tmp") + with FileLock(str(target_path) + ".lock"): + try: + tmp.write_text(content, encoding="utf-8") + tmp.replace(target_path) + except Exception: + tmp.unlink(missing_ok=True) + raise + + +def _apply_group_settings(group_id: str, payload: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + reload_config(_CONFIG_PATH) + cfg = get_config() + if cfg.load_error: + raise HTTPException(status_code=409, detail=f"awakening.toml load error: {cfg.load_error}") + before = asdict(cfg.group_overrides[group_id]) if group_id in cfg.group_overrides else None + existing = cfg.group_overrides.get(group_id) + override = AwakeningGroupOverride(**asdict(existing)) if existing is not None else AwakeningGroupOverride(group_id=group_id) + for key, value in payload.items(): + setattr(override, key, value) + next_overrides = dict(cfg.group_overrides) + values = asdict(override) + has_any_override = any(values[field_name] is not None for field_name in _ALL_GROUP_OVERRIDE_FIELDS) + if has_any_override: + next_overrides[group_id] = override + else: + next_overrides.pop(group_id, None) + next_cfg = AwakeningConfig(defaults=cfg.defaults, group_overrides=next_overrides, source_path=cfg.source_path) + _write_awakening_config(next_cfg) + reload_config(_CONFIG_PATH) + after_override = get_config().group_overrides.get(group_id) + after = asdict(after_override) if after_override is not None else None + return before or {}, after or {} + + +def _load_boredom_groups() -> set[str]: + if not _BOREDOM_GROUPS_PATH.exists(): + return set() + try: + data = json.loads(_BOREDOM_GROUPS_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return set() + groups = data.get("enabled", []) + return {str(g) for g in groups if _GROUP_ID_RE.match(str(g))} + + +def _save_boredom_groups(groups: set[str]) -> None: + _BOREDOM_GROUPS_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = _BOREDOM_GROUPS_PATH.with_suffix(".json.tmp") + tmp.write_text( + json.dumps({"enabled": sorted(groups)}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + tmp.replace(_BOREDOM_GROUPS_PATH) + + +def _known_group_ids() -> list[str]: + groups = set(stats_tracker.to_dict().keys()) + groups.update(get_config().group_overrides.keys()) + groups.update(_load_boredom_groups()) + return sorted(g for g in groups if _GROUP_ID_RE.match(str(g))) + + +def _format_group(group_id: str) -> dict: + cfg = get_config() + settings = cfg.resolve_group(group_id) + boredom_groups = _load_boredom_groups() + override = cfg.group_overrides.get(group_id) + return { + "group_id": group_id, + "rules": [ + { + "name": rule_name, + "label": label, + "enabled": rule_switch.is_enabled(group_id, rule_name), + } + for rule_name, label in _AWAKENING_RULES + ], + "settings": asdict(settings), + "override": asdict(override) if override is not None else {"group_id": group_id, **{field_name: None for field_name in _ALL_GROUP_OVERRIDE_FIELDS}}, + "has_override": group_id in cfg.group_overrides, + "boredom_opt_in": group_id in boredom_groups, + } + + +@router.get("/awakening") +def list_awakening(): + cfg = get_config() + return { + "load_error": cfg.load_error, + "source_path": str(cfg.source_path or ""), + "defaults": asdict(cfg.defaults), + "rules": [{"name": name, "label": label} for name, label in _AWAKENING_RULES], + "groups": [_format_group(group_id) for group_id in _known_group_ids()], + } + + +@router.get("/awakening/{group_id}") +def get_awakening_group(group_id: str): + _validate_group_id(group_id) + return _format_group(group_id) + + +@router.post("/awakening/{group_id}/rules/{rule_name}") +def set_awakening_rule(group_id: str, rule_name: str, body: ToggleBody, request: Request): + _validate_group_id(group_id) + if rule_name not in _VALID_RULES: + raise HTTPException(status_code=404, detail="unknown awakening rule") + old_enabled = rule_switch.is_enabled(group_id, rule_name) + if body.enabled: + rule_switch.enable(group_id, rule_name) + else: + rule_switch.disable(group_id, rule_name) + rule_switch.save(RULE_SWITCH_PATH) + audit_logger.log( + request, + action="toggle", + target_type="awakening_rule", + target_id=f"{group_id}:{rule_name}", + summary_before={"enabled": old_enabled}, + summary_after={"enabled": body.enabled}, + ) + return {"ok": True} + + +@router.post("/awakening/{group_id}/boredom") +def set_boredom_opt_in(group_id: str, body: ToggleBody, request: Request): + _validate_group_id(group_id) + groups = _load_boredom_groups() + old_enabled = group_id in groups + if body.enabled: + groups.add(group_id) + else: + groups.discard(group_id) + _save_boredom_groups(groups) + audit_logger.log( + request, + action="toggle", + target_type="awakening_boredom_group", + target_id=group_id, + summary_before={"enabled": old_enabled}, + summary_after={"enabled": body.enabled}, + ) + return {"ok": True} + + +@router.put("/awakening/{group_id}/settings") +def set_awakening_settings(group_id: str, body: AwakeningSettingsBody, request: Request): + _validate_group_id(group_id) + payload = body.model_dump(exclude_unset=True) + if not payload: + raise HTTPException(status_code=400, detail="no fields to update") + _validate_settings_payload(payload) + before, after = _apply_group_settings(group_id, payload) + action = action_queue.enqueue("rules_reload") + audit_logger.log( + request, + action="update", + target_type="awakening_settings", + target_id=group_id, + summary_before=before, + summary_after={"fields": list(payload.keys()), "override": after, "action_id": action["id"]}, + ) + return {"ok": True, "queued": True, "action": action, "group": _format_group(group_id)} diff --git a/quickquip/app/web/routes/config.py b/quickquip/app/web/routes/config.py index 3a4ff80..e0effdd 100644 --- a/quickquip/app/web/routes/config.py +++ b/quickquip/app/web/routes/config.py @@ -38,6 +38,11 @@ "label": "游戏配置", "description": "金币签到倍率、各游戏赌注/CD/超时等参数", }, + "awakening": { + "filename": "awakening.toml", + "label": "唤醒配置", + "description": "按群唤醒延长、兴趣话题、相关性判定和无聊冒泡参数", + }, "niuniu_text": { "filename": "niuniu_text.toml", "label": "牛牛文案", diff --git a/quickquip/app/web/routes/groups.py b/quickquip/app/web/routes/groups.py index 80e7281..eec7afd 100644 --- a/quickquip/app/web/routes/groups.py +++ b/quickquip/app/web/routes/groups.py @@ -2,7 +2,8 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel -from quickquip.app.message_pipeline import daily_enabled_groups, daily_briefing_enabled_groups, stats_tracker +from quickquip.app.message_pipeline import RULE_SWITCH_PATH, daily_enabled_groups, daily_briefing_enabled_groups, rule_switch, stats_tracker +from quickquip.app.web.action_queue import action_queue from quickquip.app.web.audit import audit_logger router = APIRouter() @@ -19,6 +20,10 @@ class GroupToggle(BaseModel): enabled: bool +class BriefingNowBody(BaseModel): + period: str | None = None + + @router.get("/groups/known") def get_known_groups(): return {"groups": sorted(stats_tracker.to_dict().keys())} @@ -37,8 +42,11 @@ def set_summary_group(group_id: str, body: GroupToggle, request: Request): _validate_group_id(group_id) if body.enabled: daily_enabled_groups.add(group_id) + rule_switch.enable(group_id, "daily_summary") else: daily_enabled_groups.remove(group_id) + rule_switch.disable(group_id, "daily_summary") + rule_switch.save(RULE_SWITCH_PATH) audit_logger.log( request, action="create" if body.enabled else "delete", @@ -53,8 +61,11 @@ def set_briefing_group(group_id: str, body: GroupToggle, request: Request): _validate_group_id(group_id) if body.enabled: daily_briefing_enabled_groups.add(group_id) + rule_switch.enable(group_id, "daily_briefing") else: daily_briefing_enabled_groups.remove(group_id) + rule_switch.disable(group_id, "daily_briefing") + rule_switch.save(RULE_SWITCH_PATH) audit_logger.log( request, action="create" if body.enabled else "delete", @@ -62,3 +73,40 @@ def set_briefing_group(group_id: str, body: GroupToggle, request: Request): target_id=f"briefing:{group_id}", ) return {"ok": True} + + +@router.post("/groups/summary/{group_id}/now") +def run_summary_now(group_id: str, request: Request): + _validate_group_id(group_id) + if not daily_enabled_groups.contains(group_id): + raise HTTPException(status_code=409, detail="daily summary is not enabled for this group") + action = action_queue.enqueue("summary_now", {"group_id": group_id}) + audit_logger.log( + request, + action="queue", + target_type="daily_summary", + target_id=group_id, + summary_after={"action_id": action["id"]}, + ) + return {"ok": True, "queued": True, "action": action} + + +@router.post("/groups/briefing/{group_id}/now") +def run_briefing_now(group_id: str, body: BriefingNowBody, request: Request): + _validate_group_id(group_id) + if not daily_briefing_enabled_groups.contains(group_id) or not rule_switch.is_enabled(group_id, "daily_briefing"): + raise HTTPException(status_code=409, detail="daily briefing is not enabled for this group") + from quickquip.chat.daily_briefing import normalize_period + + period = normalize_period(body.period or "") if body.period else None + if body.period and period is None: + raise HTTPException(status_code=422, detail="period must be morning, noon or evening") + action = action_queue.enqueue("briefing_now", {"group_id": group_id, "period": period}) + audit_logger.log( + request, + action="queue", + target_type="daily_briefing", + target_id=f"{group_id}:{period or 'auto'}", + summary_after={"action_id": action["id"], "period": period}, + ) + return {"ok": True, "queued": True, "action": action, "period": period} diff --git a/quickquip/app/web/routes/llm_runtime.py b/quickquip/app/web/routes/llm_runtime.py new file mode 100644 index 0000000..0740ce5 --- /dev/null +++ b/quickquip/app/web/routes/llm_runtime.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import re + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from quickquip.app.message_pipeline import _ensure_llm_bindings, get_llm_service +from quickquip.app.web.action_queue import action_queue +from quickquip.app.web.audit import audit_logger + +router = APIRouter() + +_SCOPE_KEY_RE = re.compile(r"^(?:\d{5,12}|private:\d{5,15})$") + + +class ScopeBody(BaseModel): + scope_key: str + + +class DeleteMessageBody(BaseModel): + scope_key: str + message_id: str + + +def _validate_scope_key(scope_key: str) -> str: + key = scope_key.strip() + if not _SCOPE_KEY_RE.match(key): + raise HTTPException(status_code=422, detail="scope_key must be 5-12 digits or 'private:USER_ID'") + return key + + +@router.get("/llm-runtime/health") +async def get_health(verbose: bool = False): + _ensure_llm_bindings() + svc = get_llm_service() + return {"text": await svc.format_health("0", chat_type="group", verbose=verbose)} + + +@router.post("/llm-runtime/reload") +def reload_runtime(request: Request): + action = action_queue.enqueue("llm_reload") + audit_logger.log(request, action="queue", target_type="llm_runtime", target_id="config", summary_after={"action_id": action["id"]}) + return {"ok": True, "queued": True, "action": action} + + +@router.post("/llm-runtime/mcp/reload") +def reload_mcp(request: Request): + action = action_queue.enqueue("mcp_reload") + audit_logger.log(request, action="queue", target_type="llm_runtime", target_id="mcp", summary_after={"action_id": action["id"]}) + return {"ok": True, "queued": True, "action": action} + + +@router.post("/llm-runtime/personas/reload") +def reload_personas(request: Request): + action = action_queue.enqueue("personas_reload") + audit_logger.log( + request, + action="queue", + target_type="llm_runtime", + target_id="personas", + summary_after={"action_id": action["id"]}, + ) + return {"ok": True, "queued": True, "action": action} + + +@router.post("/llm-runtime/rules/reload") +def reload_rules(request: Request): + action = action_queue.enqueue("rules_reload") + audit_logger.log( + request, + action="queue", + target_type="llm_runtime", + target_id="chat_rules", + summary_after={"action_id": action["id"]}, + ) + return {"ok": True, "queued": True, "action": action} + + +@router.post("/llm-runtime/context/clear") +def clear_context(body: ScopeBody, request: Request): + scope_key = _validate_scope_key(body.scope_key) + action = action_queue.enqueue("clear_context", {"scope_key": scope_key}) + audit_logger.log( + request, + action="queue", + target_type="llm_context", + target_id=scope_key, + summary_after={"action_id": action["id"]}, + ) + return {"ok": True, "queued": True, "action": action} + + +@router.post("/llm-runtime/context/delete-message") +def delete_message(body: DeleteMessageBody, request: Request): + scope_key = _validate_scope_key(body.scope_key) + message_id = body.message_id.strip() + if not message_id: + raise HTTPException(status_code=422, detail="message_id is required") + action = action_queue.enqueue( + "delete_context_message", + {"scope_key": scope_key, "message_id": message_id}, + ) + audit_logger.log( + request, + action="queue", + target_type="llm_context_message", + target_id=f"{scope_key}:{message_id}", + summary_after={"action_id": action["id"]}, + ) + return {"ok": True, "queued": True, "action": action} + + +@router.get("/llm-runtime/actions") +def list_actions(limit: int = 20): + return {"actions": action_queue.list_recent(limit)} diff --git a/quickquip/app/web/routes/tieba.py b/quickquip/app/web/routes/tieba.py index 33c4906..fb1a5d1 100644 --- a/quickquip/app/web/routes/tieba.py +++ b/quickquip/app/web/routes/tieba.py @@ -168,3 +168,19 @@ async def event_stream(): media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) + + +@router.get("/tieba/peek") +async def peek_tieba(forum: str = Query(..., max_length=32)): + from quickquip.tieba.errors import TiebaLoginRequiredError, TiebaServiceError + + _validate_forum(forum) + try: + thread = await tieba_service.peek_random_thread(forum) + except TiebaLoginRequiredError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except TiebaServiceError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + if thread is None: + raise HTTPException(status_code=404, detail="thread not found") + return thread.to_dict() diff --git a/quickquip/common/paths.py b/quickquip/common/paths.py index b507843..cba84b4 100644 --- a/quickquip/common/paths.py +++ b/quickquip/common/paths.py @@ -31,6 +31,7 @@ QUOTES_DB_PATH = DATA_DIR / "quotes.db" MCP_STATUS_JSON_PATH = DATA_DIR / "mcp_status.json" WEB_ADMIN_SESSIONS_DB_PATH = DATA_DIR / "web_admin_sessions.db" +WEB_ADMIN_ACTIONS_DB_PATH = DATA_DIR / "web_admin_actions.db" LLM_TRACE_JSONL_PATH = LOGS_DIR / "quickquip_trace.jsonl" LLM_VOCAB_YAML_PATH = LLM_ABOUT_DIR / "vocab.yaml" diff --git a/quickquip/llm/service_parts/health.py b/quickquip/llm/service_parts/health.py index 5174fc6..5bf51fc 100644 --- a/quickquip/llm/service_parts/health.py +++ b/quickquip/llm/service_parts/health.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json + +from quickquip.common.paths import MCP_STATUS_JSON_PATH from quickquip.common.sensitive_filter import get_filter as _get_sensitive_filter from quickquip.llm.config import PersonaConfig, ProviderConfig from quickquip.llm.health import HealthReport @@ -15,6 +18,29 @@ class HealthMixin: def _get_mcp_statuses(self) -> list[MCPServerStatus]: return self.mcp_manager.get_statuses() + def _get_shared_mcp_health(self) -> tuple[str, int] | None: + if not self.config.mcp.enabled or self._get_mcp_statuses(): + return None + try: + data = json.loads(MCP_STATUS_JSON_PATH.read_text(encoding="utf-8")) + except (FileNotFoundError, OSError, json.JSONDecodeError): + return None + + raw_statuses = data.get("statuses", []) + if not isinstance(raw_statuses, list) or not raw_statuses: + return None + statuses = [item for item in raw_statuses if isinstance(item, dict)] + if not statuses: + return None + connected = sum(1 for item in statuses if item.get("connected")) + tool_count = 0 + for item in statuses: + try: + tool_count += int(item.get("tool_count") or 0) + except (TypeError, ValueError): + pass + return f"ON ({connected}/{len(statuses)},{tool_count} tools,bot runtime)", tool_count + def format_mcp_status(self) -> str: lines = ["MCP 状态"] if not self.config.mcp.enabled: @@ -24,6 +50,11 @@ def format_mcp_status(self) -> str: lines.append("总开关:ON") if self._is_mcp_initializing(): lines.append("运行态:初始化中") + shared = self._get_shared_mcp_health() + if shared is not None and self._mcp_dirty: + summary, _tool_count = shared + lines.append(f"运行态:{summary}") + return "\n".join(lines) if self._mcp_dirty and not self._get_mcp_statuses(): lines.append("运行态:待初始化") return "\n".join(lines) @@ -51,6 +82,9 @@ def _summarize_mcp_status(self) -> str: if self._is_mcp_initializing(): return "初始化中" statuses = self._get_mcp_statuses() + shared = self._get_shared_mcp_health() + if shared is not None and self._mcp_dirty: + return shared[0] if self._mcp_dirty and not statuses: return "待初始化" if not statuses: @@ -149,7 +183,7 @@ async def build_health_report( tool_names=self._get_enabled_tool_names(chat_type=chat_type), mcp_status_summary=self._summarize_mcp_status(), mcp_enabled=self.config.mcp.enabled, - mcp_tool_count=len(self._mcp_tool_names), + mcp_tool_count=(self._get_shared_mcp_health() or ("", len(self._mcp_tool_names)))[1], recent_buffer_bound=self.recent_message_buffer is not None, stats_bound=self.stats_tracker is not None, rule_switch_bound=self.rule_switch is not None, diff --git a/tests/unit/web/test_action_queue.py b/tests/unit/web/test_action_queue.py new file mode 100644 index 0000000..76fbf34 --- /dev/null +++ b/tests/unit/web/test_action_queue.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from quickquip.app.web.action_queue import WebAdminActionQueue + + +def test_action_queue_claim_complete_and_fail(tmp_path): + queue = WebAdminActionQueue(tmp_path / "actions.db") + + first = queue.enqueue("llm_reload") + second = queue.enqueue("clear_context", {"scope_key": "12345"}) + + claimed = queue.claim(limit=1) + assert [item.id for item in claimed] == [first["id"]] + assert claimed[0].status == "running" + + queue.complete(claimed[0].id, {"ok": True}) + queue.fail(second["id"], "boom") + + recent = {item["id"]: item for item in queue.list_recent()} + assert recent[first["id"]]["status"] == "succeeded" + assert recent[first["id"]]["result"] == {"ok": True} + assert recent[second["id"]]["status"] == "failed" + assert recent[second["id"]]["error"] == "boom" diff --git a/tests/unit/web/test_awakening_routes.py b/tests/unit/web/test_awakening_routes.py new file mode 100644 index 0000000..6b4ab2b --- /dev/null +++ b/tests/unit/web/test_awakening_routes.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import pytest + +fastapi = pytest.importorskip("fastapi") +HTTPException = fastapi.HTTPException + +from quickquip.app.web.routes import awakening as awakening_route # noqa: E402 +from quickquip.chat import awakening as awakening_config # noqa: E402 + + +@pytest.fixture() +def temp_awakening_config(monkeypatch, tmp_path): + path = tmp_path / "awakening.toml" + monkeypatch.setattr(awakening_route, "_CONFIG_PATH", path) + awakening_config.reload_config(path) + try: + yield path + finally: + awakening_config.reload_config() + + +def _write_config(path, content: str) -> None: + path.write_text(content.strip() + "\n", encoding="utf-8") + awakening_config.reload_config(path) + + +def test_apply_group_settings_preserves_interest_topics(temp_awakening_config): + _write_config( + temp_awakening_config, + """ + [awakening.defaults] + fallback_probability = 0.01 + + [[awakening.group_overrides]] + group_id = "123456" + interest_topics = ["Python", "LLM"] + fallback_probability = 0.2 + """, + ) + + _before, after = awakening_route._apply_group_settings( + "123456", + {"fallback_probability": 0.35, "relevance_threshold": 0.6}, + ) + + assert after["interest_topics"] == ["Python", "LLM"] + assert after["fallback_probability"] == 0.35 + assert after["relevance_threshold"] == 0.6 + + saved = awakening_config.load_awakening_config(temp_awakening_config) + override = saved.group_overrides["123456"] + assert override.interest_topics == ["Python", "LLM"] + assert override.fallback_probability == 0.35 + assert override.relevance_threshold == 0.6 + + +def test_clearing_editable_fields_keeps_interest_only_override(temp_awakening_config): + _write_config( + temp_awakening_config, + """ + [awakening.defaults] + fallback_probability = 0.01 + + [[awakening.group_overrides]] + group_id = "123456" + interest_topics = ["Python"] + fallback_probability = 0.2 + """, + ) + + _before, after = awakening_route._apply_group_settings("123456", {"fallback_probability": None}) + + assert after["interest_topics"] == ["Python"] + assert after["fallback_probability"] is None + + saved = awakening_config.load_awakening_config(temp_awakening_config) + assert "123456" in saved.group_overrides + assert saved.group_overrides["123456"].interest_topics == ["Python"] + assert saved.group_overrides["123456"].fallback_probability is None + + +def test_validate_settings_payload_rejects_bool_numbers(): + with pytest.raises(HTTPException) as int_exc: + awakening_route._validate_settings_payload({"extend_duration": True}) + assert int_exc.value.status_code == 422 + + with pytest.raises(HTTPException) as float_exc: + awakening_route._validate_settings_payload({"fallback_probability": False}) + assert float_exc.value.status_code == 422 From c8dc3f56688a32a27b20f86bd6a72d5412b29783 Mon Sep 17 00:00:00 2001 From: 3aKHP <2971755027@qq.com> Date: Wed, 27 May 2026 09:26:23 +0800 Subject: [PATCH 2/6] fix(web-admin): keep sensitive words server-local - Remove sensitive filter path disclosure from Web Admin status - Keep sensitive_words.toml out of config editor responses - Refresh the sensitive filter on LLM config reload --- docs/admin/sensitive-filter.md | 10 +- frontend/src/views/ConfigView.vue | 4 +- quickquip/app/web/routes/sensitive_filter.py | 2 +- quickquip/llm/health.py | 4 +- quickquip/llm/service.py | 2 + tests/unit/llm/test_health.py | 15 ++ tests/unit/web/test_config_routes.py | 132 ++++++++++++++++++ .../unit/web/test_sensitive_filter_routes.py | 47 +++++++ 8 files changed, 208 insertions(+), 8 deletions(-) create mode 100644 tests/unit/web/test_config_routes.py create mode 100644 tests/unit/web/test_sensitive_filter_routes.py diff --git a/docs/admin/sensitive-filter.md b/docs/admin/sensitive-filter.md index a6b224e..0e87fb9 100644 --- a/docs/admin/sensitive-filter.md +++ b/docs/admin/sensitive-filter.md @@ -90,9 +90,11 @@ cp config/sensitive_words.toml.example config/sensitive_words.toml - **阿里云/腾讯云内容安全 API**:覆盖更全但增加延迟和成本,对群聊 bot 而言 overkill - **测试群跑半个月,记录所有模型主动拒答**——这是质量最高的源 -### 4. 重载 +### 4. 重载与状态查看 -修改 `config/sensitive_words.toml` 后,调用 `reload_filter()` 即可热更新(不需要重启 bot)。当前没有暴露给 web admin,需要时可以加个 `/reload-sensitive` 命令。 +修改 `config/sensitive_words.toml` 后,调用 `reload_filter()` 即可热更新(不需要重启 bot)。当前没有独立的群内重载命令;在服务器本地更新词表后,可执行 `/llm reload` 或重启 bot。 + +Web Admin 提供只读状态接口 `GET /ops/api/sensitive-filter/status`,返回配置文件是否存在、是否已加载以及 block/soft/total 计数。它不会返回词表内容、分类明细或文件路径。群内 `/llm health verbose` 也会展示 `sensitive_filter` 健康项,但不会回显词表路径。 ## 接入点 @@ -109,7 +111,8 @@ cp config/sensitive_words.toml.example config/sensitive_words.toml **为什么工具结果扫描尤其重要**:搜索/抓取类工具(`search_web`、`fetch`、各类 MCP 工具)从外部源拉取内容,**用户的查询可以引导但我们无法预先审查**。一段富集敏感词的 tool_result 会作为 messages 的一部分进入下一轮 provider 请求,正是触发 DeepSeek `Content Exists Risk` / Aliyun `Content security warning` 的高危场景。 **没有接入的位置**: -- `daily_summary` / `daily_briefing` / `wordcloud` 路径——这些路径的输入是已经过滤过的群聊消息,且不直接通过 LLM 输出到群里。如需加固,可以在 `quickquip/chat/daily_summary.py` 的消息收集阶段调用同一个 `get_filter()` 做扫描 +- `daily_summary` / `daily_briefing` 会走独立的模型级联 provider 调用,不经过 `LLMService.generate_reply()` 主链路,因此当前不会复用输入/输出/历史侧过滤器;如需加固,应在 `quickquip/llm/summarize.py` 与 `quickquip/llm/briefing.py` 的请求和响应边界接入同一个 `get_filter()` +- `wordcloud` 不调用 LLM,只读取群聊消息并渲染词频图片;如需避免敏感词出现在图片中,应在 `quickquip/chat/wordcloud.py` 的分词或渲染前增加扫描/剔除 ## 性能 @@ -122,6 +125,7 @@ cp config/sensitive_words.toml.example config/sensitive_words.toml ## 不要做的事 - ❌ 把 `config/sensitive_words.toml` 提交到公开仓库 +- ❌ 通过 Web Admin 或任何浏览器页面读取、回显、编辑 `config/sensitive_words.toml` - ❌ 把命中日志记得太详细(如完整原文 + 用户 ID + 时间)——日志本身会成为合规风险 - ❌ 在群里**告知用户**触发了过滤——直接静默 + 后台日志即可,告知等于教用户绕过 - ❌ 让 LLM 自己判断"这内容能不能发"——增加成本和延迟,且模型自己也不可靠 diff --git a/frontend/src/views/ConfigView.vue b/frontend/src/views/ConfigView.vue index c5d730c..c088026 100644 --- a/frontend/src/views/ConfigView.vue +++ b/frontend/src/views/ConfigView.vue @@ -1,6 +1,6 @@