From 3d671c0da06a72c6723c5e5fa520d5b27519e3c9 Mon Sep 17 00:00:00 2001 From: liuwei Date: Wed, 8 Apr 2026 14:09:21 +0800 Subject: [PATCH] feat: improve llm settings ui and douyu daily fallback --- admin/dashboard/blueprints/system.py | 100 +++++- admin/dashboard/templates/system_status.html | 318 +++++++++++++++---- plugins/douyu/main.py | 67 +++- 3 files changed, 421 insertions(+), 64 deletions(-) diff --git a/admin/dashboard/blueprints/system.py b/admin/dashboard/blueprints/system.py index 62c1566..a65216e 100644 --- a/admin/dashboard/blueprints/system.py +++ b/admin/dashboard/blueprints/system.py @@ -19,6 +19,24 @@ system_bp = Blueprint('system', __name__) APP_START_TIME = time.time() +def _system_config_path() -> str: + return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'config.yaml')) + + +def _load_system_yaml() -> dict: + config_path = _system_config_path() + if not os.path.exists(config_path): + return {} + with open(config_path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) or {} + + +def _save_system_yaml(config_obj: dict) -> None: + config_path = _system_config_path() + with open(config_path, 'w', encoding='utf-8') as f: + yaml.safe_dump(config_obj, f, allow_unicode=True, sort_keys=False) + + @system_bp.route('/api_docs') @login_required def api_docs(): @@ -162,7 +180,7 @@ def get_current_user_info(): def get_system_config_raw(): try: server = current_app.dashboard_server - config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'config.yaml')) + config_path = _system_config_path() with open(config_path, 'r', encoding='utf-8') as f: config_text = f.read() robot_config = getattr(getattr(server, "robot", None), "config", None) @@ -190,7 +208,7 @@ def update_system_config(): return jsonify({"success": False, "message": "缺少配置内容"}), 400 yaml.safe_load(config_text) - config_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..', 'config.yaml')) + config_path = _system_config_path() with open(config_path, 'w', encoding='utf-8') as f: f.write(config_text) @@ -203,6 +221,84 @@ def update_system_config(): return jsonify({"success": False, "message": str(e)}), 500 +@system_bp.route('/api/system/llm_config', methods=['GET']) +@login_required +def get_system_llm_config(): + try: + config_obj = _load_system_yaml() + llm_config = config_obj.get("llm", {}) or {} + backends = llm_config.get("backends", {}) or {} + backend_list = [] + for name, backend in backends.items(): + if not isinstance(backend, dict): + continue + item = dict(backend) + item["name"] = name + backend_list.append(item) + backend_list.sort(key=lambda item: item.get("name", "")) + return jsonify({ + "success": True, + "data": { + "default_backend": llm_config.get("default_backend", ""), + "backends": backend_list, + "config_path": _system_config_path(), + } + }) + except Exception as e: + logger.error(f"读取全局 LLM 配置失败: {e}") + return jsonify({"success": False, "message": str(e)}), 500 + + +@system_bp.route('/api/system/llm_config', methods=['POST']) +@login_required +def update_system_llm_config(): + try: + server = current_app.dashboard_server + data = request.get_json() or {} + default_backend = str(data.get("default_backend") or "").strip() + backend_list = data.get("backends", []) or [] + if not isinstance(backend_list, list): + return jsonify({"success": False, "message": "backends 格式不正确"}), 400 + + normalized_backends = {} + for raw in backend_list: + if not isinstance(raw, dict): + continue + name = str(raw.get("name") or "").strip() + if not name: + continue + item = {} + for key, value in raw.items(): + if key == "name": + continue + if value is None: + continue + if isinstance(value, str): + value = value.strip() + if value == "": + continue + item[key] = value + normalized_backends[name] = item + + if default_backend and default_backend not in normalized_backends: + return jsonify({"success": False, "message": "默认后端不存在"}), 400 + + config_obj = _load_system_yaml() + config_obj["llm"] = { + "default_backend": default_backend, + "backends": normalized_backends, + } + _save_system_yaml(config_obj) + + if getattr(server, "robot", None) and getattr(server.robot, "config", None): + server.robot.config.reload() + + return jsonify({"success": True, "message": "全局 LLM 配置已保存"}) + except Exception as e: + logger.error(f"保存全局 LLM 配置失败: {e}") + return jsonify({"success": False, "message": str(e)}), 500 + + @system_bp.route('/api/restart_service', methods=['POST']) @login_required def restart_service(): diff --git a/admin/dashboard/templates/system_status.html b/admin/dashboard/templates/system_status.html index 26ced73..f47088d 100644 --- a/admin/dashboard/templates/system_status.html +++ b/admin/dashboard/templates/system_status.html @@ -7,51 +7,159 @@
System Workspace
-

服务器资源监控

-

把系统面板统一纳入控制台视图,避免工具页与主工作流割裂。

+

系统控制台

+

把监控和全局 LLM 配置集中到同一页,减少来回切换。

- 刷新面板 - 新窗口打开 重启服务
- -
-
-

监控面板

-

直接在控制台内查看系统资源变化与运行状态。

-
-
{{ src_url }}
-
-
- -
-
+ + + +
+
+

监控面板

+

直接在控制台内查看系统资源变化与运行状态。

+
+
+
{{ src_url }}
+ 刷新 + 新窗口打开 +
+
+
+ +
+
+
- -
-
-

全局配置

-

集中维护 `config.yaml`,其中 `llm.backends` 用于统一管理所有模型后端。

-
-
- 刷新配置 - 保存配置 -
-
-
- 配置文件:{% raw %}{{ configPath }}{% endraw %} - LLM 后端:{% raw %}{{ llmBackends.join(', ') }}{% endraw %} -
- - -
+ + +
+
+

全局 LLM 配置

+

用表单统一管理 `config.yaml` 中的 `llm.backends`,插件只引用后端名。

+
+
+ 刷新 + 新增后端 + 保存配置 +
+
+ +
+ 配置文件:{% raw %}{{ configPath }}{% endraw %} + 后端数量:{% raw %}{{ llmForm.backends.length }}{% endraw %} +
+ + + + + + + + + + +
+ +
+
+ {% raw %}{{ backend.name || `后端 ${index + 1}` }}{% endraw %} +
+ 删除 +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+
+
+
+ + +
+
+
{% endblock %} @@ -63,17 +171,26 @@ data() { return { currentView: '14', - showTimeRangeSelector: false, + activeTab: 'monitor', frameUrl: '{{ src_url }}', restarting: false, - systemConfigText: '', configPath: '', - llmBackends: [] + llmForm: { + default_backend: '', + backends: [] + } + } + }, + computed: { + backendNameOptions() { + return (this.llmForm.backends || []) + .map(item => item.name) + .filter(Boolean); } }, mounted() { this.currentView = '14'; - this.loadSystemConfig(); + this.loadLlmConfig(); }, methods: { reloadIframe() { @@ -109,28 +226,89 @@ this.restarting = false; } }, - async loadSystemConfig() { - try { - const response = await axios.get('/api/system/config/raw'); - if (response.data.success) { - this.systemConfigText = response.data.data || ''; - this.configPath = response.data.path || ''; - this.llmBackends = response.data.llm_backends || []; - } else { - this.$message.error(response.data.message || '读取全局配置失败'); - } - } catch (error) { - this.$message.error(error.response?.data?.message || '读取全局配置失败'); + newBackend() { + return { + uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + name: '', + provider: 'dify', + mode: '', + model: '', + api_base_url: '', + api_url: '', + endpoint: '', + api_key: '', + response_mode: '', + workflow_output_key: '', + timeout_seconds: 60, + request_timeout: 60, + temperature: 0.7, + max_tokens: 1024, + max_retries: 3, + retry_delay_seconds: 1.0, + stream: false + }; + }, + normalizeBackend(item) { + return { + uid: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + name: item.name || '', + provider: item.provider || 'dify', + mode: item.mode || '', + model: item.model || '', + api_base_url: item.api_base_url || '', + api_url: item.api_url || '', + endpoint: item.endpoint || '', + api_key: item.api_key || '', + response_mode: item.response_mode || '', + workflow_output_key: item.workflow_output_key || '', + timeout_seconds: item.timeout_seconds ?? 60, + request_timeout: item.request_timeout ?? 60, + temperature: item.temperature ?? 0.7, + max_tokens: item.max_tokens ?? 1024, + max_retries: item.max_retries ?? 3, + retry_delay_seconds: item.retry_delay_seconds ?? 1.0, + stream: !!item.stream + }; + }, + addBackend() { + this.llmForm.backends.push(this.newBackend()); + }, + removeBackend(index) { + const removed = this.llmForm.backends[index]; + this.llmForm.backends.splice(index, 1); + if (removed && removed.name && this.llmForm.default_backend === removed.name) { + this.llmForm.default_backend = ''; } }, - async saveSystemConfig() { + async loadLlmConfig() { try { - const response = await axios.post('/api/system/config/update', { - config_text: this.systemConfigText - }); + const response = await axios.get('/api/system/llm_config'); + if (response.data.success) { + const data = response.data.data || {}; + this.configPath = data.config_path || ''; + this.llmForm.default_backend = data.default_backend || ''; + this.llmForm.backends = (data.backends || []).map(item => this.normalizeBackend(item)); + } else { + this.$message.error(response.data.message || '读取全局 LLM 配置失败'); + } + } catch (error) { + this.$message.error(error.response?.data?.message || '读取全局 LLM 配置失败'); + } + }, + async saveLlmConfig() { + const payload = { + default_backend: this.llmForm.default_backend || '', + backends: (this.llmForm.backends || []).map(item => { + const cleaned = { ...item }; + delete cleaned.uid; + return cleaned; + }) + }; + try { + const response = await axios.post('/api/system/llm_config', payload); if (response.data.success) { this.$message.success(response.data.message || '保存成功'); - this.loadSystemConfig(); + this.loadLlmConfig(); } else { this.$message.error(response.data.message || '保存失败'); } @@ -153,6 +331,7 @@ .page-eyebrow { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #6366f1; font-weight: 700; margin-bottom: 8px; } .page-hero-copy h1 { font-size: 30px; line-height: 1.1; margin-bottom: 10px; color: #0f172a; } .page-hero-copy p { color: #64748b; font-size: 14px; } + .system-tabs { width: 100%; } .workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .workspace-header h3 { font-size: 18px; margin-bottom: 4px; } .workspace-header p { font-size: 13px; color: #64748b; } @@ -160,11 +339,28 @@ max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: #94a3b8; padding: 8px 12px; border-radius: 999px; background: rgba(248,250,252,0.9); border: 1px solid rgba(148,163,184,0.12); } - .iframe-shell-card { height: calc(100vh - 230px); } + .iframe-shell-card { height: calc(100vh - 280px); } .iframe-shell-card .el-card__body { height: calc(100% - 73px); } .iframe-shell { height: 100%; border-radius: 18px; overflow: hidden; border: 1px solid rgba(148,163,184,0.12); background: rgba(248,250,252,0.82); } .iframe-shell iframe { width: 100%; height: 100%; border: none; display: block; background: #fff; } - .workspace-card .el-card__body { display: flex; flex-direction: column; gap: 12px; } + .workspace-card .el-card__body { display: flex; flex-direction: column; gap: 16px; } .config-meta { display: flex; justify-content: space-between; gap: 12px; color: #64748b; font-size: 12px; } + .backend-list { display: flex; flex-direction: column; gap: 16px; } + .backend-card { border-radius: 18px; border: 1px solid rgba(148,163,184,0.16); } + .backend-card-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + .backend-grid { + display: grid; + grid-template-columns: repeat(2, minmax(260px, 1fr)); + gap: 8px 16px; + } + .backend-switches { display: flex; align-items: center; gap: 16px; padding: 0 12px 8px; } + .danger-text { color: #dc2626; } + @media (max-width: 960px) { + .page-hero { flex-direction: column; align-items: flex-start; } + .workspace-header { flex-direction: column; align-items: flex-start; } + .page-hero-actions { flex-wrap: wrap; } + .iframe-url { max-width: 100%; } + .backend-grid { grid-template-columns: 1fr; } + } {% endblock %} diff --git a/plugins/douyu/main.py b/plugins/douyu/main.py index ccc6b0e..a381eb4 100644 --- a/plugins/douyu/main.py +++ b/plugins/douyu/main.py @@ -1027,7 +1027,60 @@ class DouyuPlugin(MessagePluginInterface): sessions.sort( key=lambda item: str(((item.get("segments") or [{}])[0]).get("start_time", "")), ) - return sessions[:self._daily_report_max_sessions] + if sessions: + return sessions[:self._daily_report_max_sessions] + + inferred_sessions = self._infer_sessions_for_anchor_day(room_id, anchor_day) + if inferred_sessions: + logger.info( + f"斗鱼每日报告使用弹幕文件回推 session: room={room_id}, day={anchor_day}, " + f"count={len(inferred_sessions)}" + ) + return inferred_sessions[:self._daily_report_max_sessions] + return [] + + def _infer_sessions_for_anchor_day(self, room_id: str, anchor_day: str) -> List[Dict[str, Any]]: + date_key = anchor_day.replace("-", "") + day_messages = DouyuDanmuSummaryHelper.load_day_messages(room_id, date_key) + if not day_messages: + return [] + + inferred_sessions = DouyuDanmuSummaryHelper.infer_sessions_from_messages( + room_id, + day_messages, + session_cutoff_hour=self._session_cutoff_hour, + merge_gap_hours=self._merge_gap_hours, + min_session_messages=min(50, self._daily_report_min_messages), + ) + + inferred_sessions = [ + item for item in inferred_sessions + if str(item.get("anchor_day") or "") == anchor_day + ] + if inferred_sessions: + return inferred_sessions + + if len(day_messages) < self._daily_report_min_messages: + return [] + + ordered = sorted(day_messages, key=lambda item: item.get("timestamp") or datetime.min) + start_dt = ordered[0].get("timestamp") + end_dt = ordered[-1].get("timestamp") + if not isinstance(start_dt, datetime) or not isinstance(end_dt, datetime): + return [] + return [{ + "session_id": f"{room_id}_{date_key}_fallback", + "room_id": room_id, + "anchor_day": anchor_day, + "nickname": "", + "room_name": "", + "segments": [{ + "start_time": start_dt.strftime("%Y-%m-%d %H:%M:%S"), + "end_time": end_dt.strftime("%Y-%m-%d %H:%M:%S"), + }], + "is_live": False, + "source": "fallback_full_day", + }] def _build_daily_report_payload(self, room_id: str, anchor_day: str, sessions: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: if not sessions: @@ -1516,17 +1569,29 @@ class DouyuPlugin(MessagePluginInterface): if target_group_id else self.redis_manager.all_subscribed_rooms() ) + if not rooms: + logger.info( + f"斗鱼每日报告无可处理房间: day={anchor_day}, target_group={target_group_id or 'ALL'}" + ) + return False delivered_any = False for room_id in rooms: if not force and self.redis_manager.get_text_value(self._daily_report_room_key(room_id, anchor_day)): + logger.info(f"斗鱼每日报告已发送过,跳过: room={room_id}, day={anchor_day}") continue sessions = self._load_sessions_for_anchor_day(room_id, anchor_day) if not sessions: + logger.info(f"斗鱼每日报告无 session: room={room_id}, day={anchor_day}") continue if any(bool(session.get("is_live")) for session in sessions): + logger.info(f"斗鱼每日报告存在直播中场次,跳过: room={room_id}, day={anchor_day}") continue payload = self._build_daily_report_payload(room_id, anchor_day, sessions) if not payload: + logger.info( + f"斗鱼每日报告 payload 为空: room={room_id}, day={anchor_day}, " + f"sessions={len(sessions)}, min_messages={self._daily_report_min_messages}" + ) continue report_result = await self._get_or_create_daily_report_result(room_id, anchor_day, payload) report_text = str(report_result.get("report_text") or "").strip()