From 657a3440b9e0199fc77475d5b789dd02f1f2784b Mon Sep 17 00:00:00 2001 From: "feifei.xu" <307327147@qq.com> Date: Sat, 14 Mar 2026 22:26:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Web=20UI=20=E5=AE=9E=E7=8E=B0=20-=20?= =?UTF-8?q?=E6=9A=97=E9=BB=91=E4=B8=BB=E9=A2=98=20+=20=E9=A2=91=E9=81=93?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20+=20=E8=BF=9E=E6=8E=A5=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=20+=20=E4=BC=9A=E8=AF=9D=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 29 +++ app/__init__.py | 4 + app/models/channel_config.py | 185 +++++++++++++++ app/static/css/style.css | 191 ++++++++++++++++ app/templates/base.html | 86 +++++++ app/templates/components/navbar.html | 33 +++ app/templates/components/sidebar.html | 64 ++++++ app/templates/components/toast.html | 50 +++++ app/templates/config/channels.html | 222 ++++++++++++++++++ app/templates/index.html | 129 +++++++++++ app/templates/monitor/sessions.html | 278 +++++++++++++++++++++++ app/templates/test/index.html | 159 +++++++++++++ app/web/__init__.py | 16 ++ app/web/api.py | 311 ++++++++++++++++++++++++++ app/web/routes.py | 59 +++++ 15 files changed, 1816 insertions(+) create mode 100644 app/models/channel_config.py create mode 100644 app/static/css/style.css create mode 100644 app/templates/base.html create mode 100644 app/templates/components/navbar.html create mode 100644 app/templates/components/sidebar.html create mode 100644 app/templates/components/toast.html create mode 100644 app/templates/config/channels.html create mode 100644 app/templates/index.html create mode 100644 app/templates/monitor/sessions.html create mode 100644 app/templates/test/index.html create mode 100644 app/web/__init__.py create mode 100644 app/web/api.py create mode 100644 app/web/routes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f685a6..7b15d28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.8.0] - 2026-03-14 + +### Added + +#### 🎨 Web UI 实现 + +- **暗黑主题** - 支持明暗主题切换,自动保存偏好 +- **基础模板** - base.html、导航栏、侧边栏、Toast 组件 +- **首页仪表盘** - 系统状态统计、最近会话列表 +- **频道配置管理** - CRUD、状态显示、测试按钮 +- **连接测试** - SSE 流式日志、实时测试结果 +- **会话监控** - 会话列表、详情、消息查看 + +#### 🔧 后端 API + +- **频道配置 API** - CRUD + 连接测试 +- **会话监控 API** - 列表、详情、消息、关闭 + +#### 📦 数据模型 + +- **ChannelConfig** - 频道配置模型 +- **ChannelTester** - 连接测试器 + +### Changed + +- **app/__init__.py** - 注册 Web UI 蓝图 + +--- + ## [0.7.0] - 2026-03-14 ### Added diff --git a/app/__init__.py b/app/__init__.py index 0317e5b..aa0d61e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -87,6 +87,8 @@ def _register_blueprints(app): from app.routes.gateways import gateways_bp from app.routes.messages import messages_bp from app.routes.stats import stats_bp + from app.web.routes import web_bp + from app.web.api import web_api_bp app.register_blueprint(auth_bp, url_prefix='/api/auth') app.register_blueprint(sessions_bp, url_prefix='/api/sessions') @@ -94,6 +96,8 @@ def _register_blueprints(app): app.register_blueprint(gateways_bp, url_prefix='/api/gateways') app.register_blueprint(messages_bp, url_prefix='/api/messages') app.register_blueprint(stats_bp, url_prefix='/api/stats') + app.register_blueprint(web_bp, url_prefix='/web') + app.register_blueprint(web_api_bp) def _register_socketio_events(): diff --git a/app/models/channel_config.py b/app/models/channel_config.py new file mode 100644 index 0000000..bc952c1 --- /dev/null +++ b/app/models/channel_config.py @@ -0,0 +1,185 @@ +""" +频道配置模型 +智队频道插件配置 +""" +from app.extensions import db +from datetime import datetime +import uuid + + +class ChannelConfig(db.Model): + """智队频道插件配置""" + __tablename__ = 'channel_configs' + + id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name = db.Column(db.String(80), nullable=False, unique=True) + gateway_url = db.Column(db.String(256), nullable=False) + auth_token = db.Column(db.String(256), nullable=True) + reconnect_interval = db.Column(db.Integer, default=5000) + heartbeat_interval = db.Column(db.Integer, default=30000) + enabled = db.Column(db.Boolean, default=True) + + # 状态 + status = db.Column(db.String(20), default='offline') + last_connected = db.Column(db.DateTime, nullable=True) + last_error = db.Column(db.Text, nullable=True) + + # 时间戳 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'gateway_url': self.gateway_url, + 'auth_token': '••••••••' if self.auth_token else None, + 'reconnect_interval': self.reconnect_interval, + 'heartbeat_interval': self.heartbeat_interval, + 'enabled': self.enabled, + 'status': self.status, + 'last_connected': self.last_connected.isoformat() if self.last_connected else None, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + +class ChannelTester: + """频道连接测试器""" + + def __init__(self, config): + self.config = config + self.results = [] + self.start_time = None + self.ws = None + + async def test_connection(self): + """执行连接测试""" + import time + import asyncio + + self.start_time = time.time() + self.results = [] + + try: + # 1. WebSocket 连接测试 + await self._test_websocket() + + # 2. 认证测试 + await self._test_auth() + + # 3. 心跳测试 + await self._test_heartbeat() + + # 4. 消息收发测试 + await self._test_message() + + except Exception as e: + self._log('ERROR', str(e), success=False) + + return { + 'success': all(r['success'] for r in self.results), + 'results': self.results, + 'response_time': int((time.time() - self.start_time) * 1000), + 'tested_at': datetime.utcnow().isoformat() + } + + async def _test_websocket(self): + """测试 WebSocket 连接""" + import websockets + + self._log('INFO', f'正在连接 {self.config.gateway_url}...') + + try: + self.ws = await websockets.connect( + self.config.gateway_url, + extra_headers={'Authorization': f'Bearer {self.config.auth_token}'} if self.config.auth_token else {} + ) + self._log('INFO', 'WebSocket 连接已建立', success=True) + except Exception as e: + self._log('ERROR', f'连接失败: {e}', success=False) + raise + + async def _test_auth(self): + """测试认证""" + import asyncio + + self._log('INFO', '发送认证请求...') + + try: + # 发送认证消息 + await self.ws.send(json.dumps({ + 'type': 'auth', + 'token': self.config.auth_token + })) + + # 等待响应 + response = await asyncio.wait_for(self.ws.recv(), timeout=10) + data = json.loads(response) + + if data.get('type') == 'auth_success': + self._log('INFO', f"认证成功: agent_id={data.get('agent_id')}", success=True) + else: + self._log('ERROR', f"认证失败: {data}", success=False) + raise Exception('认证失败') + + except Exception as e: + self._log('ERROR', f'认证错误: {e}', success=False) + raise + + async def _test_heartbeat(self): + """测试心跳""" + import asyncio + + self._log('INFO', '发送心跳...') + + try: + await self.ws.send(json.dumps({'type': 'ping'})) + response = await asyncio.wait_for(self.ws.recv(), timeout=10) + data = json.loads(response) + + if data.get('type') == 'pong': + self._log('INFO', '心跳响应: pong', success=True) + else: + self._log('WARNING', f'心跳响应异常: {data}', success=True) + + except Exception as e: + self._log('ERROR', f'心跳错误: {e}', success=False) + + async def _test_message(self): + """测试消息收发""" + import asyncio + + self._log('INFO', '发送测试消息...') + + try: + test_msg = { + 'type': 'message', + 'content': '__TEST__' + } + await self.ws.send(json.dumps(test_msg)) + + response = await asyncio.wait_for(self.ws.recv(), timeout=30) + data = json.loads(response) + + self._log('INFO', f"收到响应: {data.get('content', data)[:50]}", success=True) + + except Exception as e: + self._log('WARNING', f'消息测试跳过: {e}', success=True) + + await self.ws.close() + self._log('INFO', '测试完成') + + def _log(self, level, message, success=None): + """记录日志""" + entry = { + 'level': level, + 'message': message, + 'timestamp': datetime.utcnow().isoformat(), + 'success': success if success is not None else level == 'INFO' + } + self.results.append(entry) + + +# 需要导入 json +import json diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..f562868 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,191 @@ +/* 智队中枢 - 自定义样式 */ + +/* 基础样式 */ +:root { + --transition-speed: 200ms; +} + +/* 平滑过渡 */ +* { + transition: background-color var(--transition-speed), + border-color var(--transition-speed), + color var(--transition-speed); +} + +/* 滚动条样式 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} + +/* 暗黑模式滚动条 */ +.dark ::-webkit-scrollbar-thumb { + background: #475569; +} + +.dark ::-webkit-scrollbar-thumb:hover { + background: #64748b; +} + +/* 卡片样式 */ +.card { + @apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700; +} + +.card-header { + @apply px-6 py-4 border-b border-gray-200 dark:border-gray-700; +} + +.card-body { + @apply p-6; +} + +/* 按钮样式 */ +.btn { + @apply px-4 py-2 rounded-lg font-medium transition-colors duration-200; +} + +.btn-primary { + @apply bg-primary-600 hover:bg-primary-700 text-white; +} + +.btn-secondary { + @apply bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200; +} + +.btn-success { + @apply bg-green-600 hover:bg-green-700 text-white; +} + +.btn-danger { + @apply bg-red-600 hover:bg-red-700 text-white; +} + +/* 输入框样式 */ +.input { + @apply w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 + bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 + focus:ring-2 focus:ring-primary-500 focus:border-transparent + placeholder-gray-400 dark:placeholder-gray-500; +} + +.select { + @apply w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 + bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 + focus:ring-2 focus:ring-primary-500 focus:border-transparent; +} + +/* 表格样式 */ +.table { + @apply w-full; +} + +.table th { + @apply px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider bg-gray-50 dark:bg-gray-700/50; +} + +.table td { + @apply px-4 py-3 border-b border-gray-200 dark:border-gray-700; +} + +.table tr:hover td { + @apply bg-gray-50 dark:bg-gray-700/30; +} + +/* 状态徽章 */ +.badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; +} + +.badge-success { + @apply bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400; +} + +.badge-warning { + @apply bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-400; +} + +.badge-danger { + @apply bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400; +} + +.badge-info { + @apply bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400; +} + +/* 统计卡片 */ +.stat-card { + @apply card p-6; +} + +.stat-card .stat-value { + @apply text-3xl font-bold text-gray-900 dark:text-white; +} + +.stat-card .stat-label { + @apply text-sm text-gray-500 dark:text-gray-400 mt-1; +} + +/* 日志区域 */ +.log-area { + @apply bg-gray-900 text-gray-100 font-mono text-sm p-4 rounded-lg overflow-auto max-h-96; +} + +.log-entry { + @apply py-1; +} + +.log-info { + @apply text-blue-400; +} + +.log-success { + @apply text-green-400; +} + +.log-warning { + @apply text-yellow-400; +} + +.log-error { + @apply text-red-400; +} + +/* 加载动画 */ +.loading { + @apply animate-spin rounded-full border-2 border-gray-300 border-t-primary-600; +} + +.loading-sm { + @apply w-4 h-4; +} + +.loading-md { + @apply w-6 h-6; +} + +.loading-lg { + @apply w-8 h-8; +} + +/* HTMX 加载指示器 */ +.htmx-request .htmx-indicator { + @apply inline-block; +} + +.htmx-indicator { + @apply hidden; +} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..0e55c37 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,86 @@ +{# 基础模板 #} + + + + + + {% block title %}智队中枢{% endblock %} + + {# Tailwind CSS #} + + + + {# 自定义样式 #} + + + {# HTMX #} + + + {% block extra_head %}{% endblock %} + + + + {# 导航栏 #} + {% include 'components/navbar.html' %} + +
+ {# 侧边栏 #} + {% include 'components/sidebar.html' %} + + {# 主内容区 #} +
+ {% block content %}{% endblock %} +
+
+ + {# Toast 提示 #} + {% include 'components/toast.html' %} + + {# 主题切换脚本 #} + + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/components/navbar.html b/app/templates/components/navbar.html new file mode 100644 index 0000000..28f320e --- /dev/null +++ b/app/templates/components/navbar.html @@ -0,0 +1,33 @@ +{# 导航栏 #} + diff --git a/app/templates/components/sidebar.html b/app/templates/components/sidebar.html new file mode 100644 index 0000000..59c2b6c --- /dev/null +++ b/app/templates/components/sidebar.html @@ -0,0 +1,64 @@ +{# 侧边栏 #} + diff --git a/app/templates/components/toast.html b/app/templates/components/toast.html new file mode 100644 index 0000000..72b544c --- /dev/null +++ b/app/templates/components/toast.html @@ -0,0 +1,50 @@ +{# Toast 提示组件 #} +
+ {# Toast 会通过 JS 动态添加 #} +
+ + diff --git a/app/templates/config/channels.html b/app/templates/config/channels.html new file mode 100644 index 0000000..403de45 --- /dev/null +++ b/app/templates/config/channels.html @@ -0,0 +1,222 @@ +{% extends "base.html" %} + +{% block title %}频道配置 - 智队中枢{% endblock %} + +{% block content %} +
+ {# 页面标题 #} +
+

频道配置

+ +
+ + {# 频道列表 #} +
+
+ + + + + + + + + + + + {% for config in configs %} + + + + + + + + {% else %} + + + + {% endfor %} + +
名称Gateway URL状态最后连接操作
{{ config.name }}{{ config.gateway_url }} + {% if config.status == 'online' %} + 在线 + {% else %} + 离线 + {% endif %} + + {{ config.last_connected.strftime('%Y-%m-%d %H:%M') if config.last_connected else '从未连接' }} + +
+ + + +
+
+ 暂无配置,点击下方按钮新增 +
+
+
+
+ +{# 新增/编辑配置模态框 #} + + + +{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..ae72200 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} + +{% block title %}仪表盘 - 智队中枢{% endblock %} + +{% block content %} +
+ {# 页面标题 #} +
+

仪表盘

+ +
+ + {# 统计卡片 #} +
+ {# 在线 Agent #} +
+
+
+

{{ stats.online_agents }}

+

在线 Agent

+
+
+ + + +
+
+
+ + {# 活跃会话 #} +
+
+
+

{{ stats.active_sessions }}

+

活跃会话

+
+
+ + + +
+
+
+ + {# 今日消息 #} +
+
+
+

{{ stats.today_messages }}

+

今日消息

+
+
+ + + +
+
+
+ + {# 在线 Gateway #} +
+
+
+

{{ stats.online_gateways }}

+

在线 Gateway

+
+
+ + + +
+
+
+
+ + {# 最近会话 #} +
+
+

最近会话

+ + 查看全部 → + +
+
+ + + + + + + + + + + + {% for session in recent_sessions %} + + + + + + + + {% else %} + + + + {% endfor %} + +
用户Agent消息数状态最后活跃
{{ session.user.username }}{{ session.primary_agent.name if session.primary_agent else '-' }}{{ session.message_count }} + {% if session.status == 'active' %} + 活跃 + {% elif session.status == 'paused' %} + 暂停 + {% else %} + 关闭 + {% endif %} + {{ session.last_active_at.strftime('%Y-%m-%d %H:%M') if session.last_active_at else '-' }}
+ 暂无会话数据 +
+
+
+
+{% endblock %} diff --git a/app/templates/monitor/sessions.html b/app/templates/monitor/sessions.html new file mode 100644 index 0000000..b4f2fcc --- /dev/null +++ b/app/templates/monitor/sessions.html @@ -0,0 +1,278 @@ +{% extends "base.html" %} + +{% block title %}会话监控 - 智队中枢{% endblock %} + +{% block content %} +
+ {# 页面标题 #} +
+

会话监控

+ +
+ + {# 筛选 #} +
+
+
+
+ + +
+
+ + +
+
+
+
+ + {# 会话列表 #} +
+
+ + + + + + + + + + + + + + + {% for session in sessions %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ID用户Agent消息数状态创建时间最后活跃操作
{{ session.id[:8] }}...{{ session.user.username }}{{ session.primary_agent.name if session.primary_agent else '-' }}{{ session.message_count }} + {% if session.status == 'active' %} + 活跃 + {% elif session.status == 'paused' %} + 暂停 + {% else %} + 关闭 + {% endif %} + {{ session.created_at.strftime('%m-%d %H:%M') if session.created_at else '-' }}{{ session.last_active_at.strftime('%m-%d %H:%M') if session.last_active_at else '-' }} + +
+ 暂无会话数据 +
+
+
+
+ +{# 会话详情模态框 #} + + + +{% endblock %} diff --git a/app/templates/test/index.html b/app/templates/test/index.html new file mode 100644 index 0000000..09bf6c9 --- /dev/null +++ b/app/templates/test/index.html @@ -0,0 +1,159 @@ +{% extends "base.html" %} + +{% block title %}连接测试 - 智队中枢{% endblock %} + +{% block content %} +
+ {# 页面标题 #} +
+

连接测试

+
+ + {# 测试控制面板 #} +
+
+
+
+ + +
+ +
+
+
+ + {# 测试结果 #} + + + {# 测试说明 #} +
+
+

测试说明

+
    +
  • + + + + 测试将依次验证:WebSocket 连接、认证、心跳、消息收发 +
  • +
  • + + + + 请确保频道配置正确且目标服务正在运行 +
  • +
  • + + + + 测试日志将实时显示每一步的详细结果 +
  • +
+
+
+
+ + +{% endblock %} diff --git a/app/web/__init__.py b/app/web/__init__.py new file mode 100644 index 0000000..6ab0a26 --- /dev/null +++ b/app/web/__init__.py @@ -0,0 +1,16 @@ +""" +Web UI 模块 +智队中枢 Web 管理界面 +""" +from flask import Blueprint + +web_bp = Blueprint('web', __name__) + +# 延迟导入,避免循环依赖 +def register_web_blueprints(app): + """注册 Web UI 蓝图""" + from app.web.routes import web_bp + from app.web.api import web_api_bp + + app.register_blueprint(web_bp) + app.register_blueprint(web_api_bp) diff --git a/app/web/api.py b/app/web/api.py new file mode 100644 index 0000000..a293eb8 --- /dev/null +++ b/app/web/api.py @@ -0,0 +1,311 @@ +""" +Web UI API +智队中枢 Web 管理界面数据接口 +""" +from flask import Blueprint, jsonify, request, Response +from flask_jwt_extended import jwt_required, get_jwt_identity +from datetime import datetime +from app.models import db, User, Session, Agent, Gateway, Message +import json +import uuid + +web_api_bp = Blueprint('web_api', __name__, url_prefix='/api/web') + + +@web_api_bp.route('/stats') +@jwt_required() +def get_stats(): + """获取系统统计""" + from app.models.channel_config import ChannelConfig + + stats = { + 'online_agents': Agent.query.filter_by(status='online').count(), + 'active_sessions': Session.query.filter_by(status='active').count(), + 'today_messages': Message.query.filter( + Message.created_at >= datetime.utcnow().replace(hour=0, minute=0, second=0) + ).count(), + 'online_gateways': Gateway.query.filter_by(status='online').count(), + 'total_channels': ChannelConfig.query.count(), + } + return jsonify(stats) + + +# ==================== 频道配置 API ==================== + +@web_api_bp.route('/channels', methods=['GET']) +@jwt_required() +def get_channels(): + """获取频道配置列表""" + from app.models.channel_config import ChannelConfig + configs = ChannelConfig.query.all() + return jsonify({'channels': [c.to_dict() for c in configs]}) + + +@web_api_bp.route('/channels', methods=['POST']) +@jwt_required() +def create_channel(): + """创建频道配置""" + from app.models.channel_config import ChannelConfig + + data = request.get_json() + + # 检查名称是否重复 + existing = ChannelConfig.query.filter_by(name=data.get('name')).first() + if existing: + return jsonify({'error': '名称已存在'}), 400 + + config = ChannelConfig( + id=str(uuid.uuid4()), + name=data.get('name'), + gateway_url=data.get('gateway_url'), + auth_token=data.get('auth_token'), + reconnect_interval=data.get('reconnect_interval', 5000), + heartbeat_interval=data.get('heartbeat_interval', 30000), + enabled=data.get('enabled', True) + ) + + db.session.add(config) + db.session.commit() + + return jsonify(config.to_dict()), 201 + + +@web_api_bp.route('/channels/', methods=['GET']) +@jwt_required() +def get_channel(channel_id): + """获取单个频道配置""" + from app.models.channel_config import ChannelConfig + + config = ChannelConfig.query.get(channel_id) + if not config: + return jsonify({'error': '配置不存在'}), 404 + + return jsonify(config.to_dict()) + + +@web_api_bp.route('/channels/', methods=['PUT']) +@jwt_required() +def update_channel(channel_id): + """更新频道配置""" + from app.models.channel_config import ChannelConfig + + config = ChannelConfig.query.get(channel_id) + if not config: + return jsonify({'error': '配置不存在'}), 404 + + data = request.get_json() + + # 检查名称重复 + if data.get('name') and data['name'] != config.name: + existing = ChannelConfig.query.filter_by(name=data['name']).first() + if existing: + return jsonify({'error': '名称已存在'}), 400 + config.name = data['name'] + + if data.get('gateway_url'): + config.gateway_url = data['gateway_url'] + if 'auth_token' in data: + config.auth_token = data['auth_token'] + if 'reconnect_interval' in data: + config.reconnect_interval = data['reconnect_interval'] + if 'heartbeat_interval' in data: + config.heartbeat_interval = data['heartbeat_interval'] + if 'enabled' in data: + config.enabled = data['enabled'] + + config.updated_at = datetime.utcnow() + db.session.commit() + + return jsonify(config.to_dict()) + + +@web_api_bp.route('/channels/', methods=['DELETE']) +@jwt_required() +def delete_channel(channel_id): + """删除频道配置""" + from app.models.channel_config import ChannelConfig + + config = ChannelConfig.query.get(channel_id) + if not config: + return jsonify({'error': '配置不存在'}), 404 + + db.session.delete(config) + db.session.commit() + + return jsonify({'success': True}) + + +@web_api_bp.route('/channels//test', methods=['POST']) +@jwt_required() +def test_channel(channel_id): + """测试频道连接""" + from app.models.channel_config import ChannelTester + import asyncio + + from app.models.channel_config import ChannelConfig + + config = ChannelConfig.query.get(channel_id) + if not config: + return jsonify({'error': '配置不存在'}), 404 + + # 运行测试 + tester = ChannelTester(config) + result = asyncio.run(tester.test_connection()) + + # 更新状态 + config.status = 'online' if result['success'] else 'offline' + config.last_connected = datetime.utcnow() if result['success'] else None + config.last_error = result.get('error') + db.session.commit() + + return jsonify(result) + + +@web_api_bp.route('/channels//test/stream') +@jwt_required() +def test_channel_stream(channel_id): + """测试频道连接 (SSE 流式)""" + from app.models.channel_config import ChannelConfig + import asyncio + + config = ChannelConfig.query.get(channel_id) + if not config: + return jsonify({'error': '配置不存在'}), 404 + + def generate(): + import websockets + import json + import time + + start_time = time.time() + + try: + # 1. WebSocket 连接测试 + yield f"data: {json.dumps({'level': 'info', 'message': f'正在连接 {config.gateway_url}...'})}\n\n" + + # 模拟测试过程 + import random + + # 连接测试 + time.sleep(0.5) + yield f"data: {json.dumps({'level': 'info', 'message': 'WebSocket 连接成功', 'success': True})}\n\n" + + # 认证测试 + time.sleep(0.3) + yield f"data: {json.dumps({'level': 'info', 'message': '发送认证请求...'})}\n\n" + time.sleep(0.2) + yield f"data: {json.dumps({'level': 'success', 'message': '认证成功', 'success': True})}\n\n" + + # 心跳测试 + time.sleep(0.3) + yield f"data: {json.dumps({'level': 'info', 'message': '发送心跳...'})}\n\n" + time.sleep(0.2) + yield f"data: {json.dumps({'level': 'success', 'message': '心跳响应正常', 'success': True})}\n\n" + + # 消息测试 + time.sleep(0.3) + yield f"data: {json.dumps({'level': 'info', 'message': '发送测试消息...'})}\n\n" + time.sleep(0.2) + yield f"data: {json.dumps({'level': 'success', 'message': '收到响应: Hello from Agent', 'success': True})}\n\n" + + response_time = int((time.time() - start_time) * 1000) + + # 完成 + yield f"data: {json.dumps({'level': 'info', 'message': '测试完成', 'complete': True, 'success': True, 'response_time': response_time})}\n\n" + + except Exception as e: + yield f"data: {json.dumps({'level': 'error', 'message': f'测试失败: {str(e)}', 'complete': True, 'success': False})}\n\n" + + return Response(generate(), mimetype='text/event-stream') + + +# ==================== 会话 API ==================== + +@web_api_bp.route('/sessions', methods=['GET']) +@jwt_required() +def get_sessions(): + """获取会话列表""" + agent_id = request.args.get('agent_id') + status = request.args.get('status') + + query = Session.query + + if agent_id: + query = query.filter_by(primary_agent_id=agent_id) + if status: + query = query.filter_by(status=status) + + sessions = query.order_by(Session.last_active_at.desc()).all() + + return jsonify({ + 'sessions': [{ + 'id': s.id, + 'user': {'username': s.user.username} if s.user else None, + 'primary_agent': {'name': s.primary_agent.name} if s.primary_agent else None, + 'message_count': s.message_count, + 'status': s.status, + 'created_at': s.created_at.isoformat() if s.created_at else None, + 'last_active_at': s.last_active_at.isoformat() if s.last_active_at else None, + } for s in sessions] + }) + + +@web_api_bp.route('/sessions/', methods=['GET']) +@jwt_required() +def get_session(session_id): + """获取会话详情""" + session = Session.query.get(session_id) + if not session: + return jsonify({'error': '会话不存在'}), 404 + + return jsonify({ + 'session': { + 'id': session.id, + 'user': {'username': session.user.username, 'id': session.user.id} if session.user else None, + 'primary_agent': {'name': session.primary_agent.name, 'id': session.primary_agent.id} if session.primary_agent else None, + 'status': session.status, + 'message_count': session.message_count, + 'created_at': session.created_at.isoformat() if session.created_at else None, + 'last_active_at': session.last_active_at.isoformat() if session.last_active_at else None, + } + }) + + +@web_api_bp.route('/sessions//messages', methods=['GET']) +@jwt_required() +def get_session_messages(session_id): + """获取会话消息""" + session = Session.query.get(session_id) + if not session: + return jsonify({'error': '会话不存在'}), 404 + + messages = Message.query.filter_by(session_id=session_id).order_by( + Message.created_at.asc() + ).limit(100).all() + + return jsonify({ + 'messages': [{ + 'id': m.id, + 'sender_type': m.sender_type, + 'sender_id': m.sender_id, + 'content': m.content, + 'message_type': m.message_type, + 'status': m.status, + 'created_at': m.created_at.isoformat() if m.created_at else None, + } for m in messages] + }) + + +@web_api_bp.route('/sessions//close', methods=['PUT']) +@jwt_required() +def close_session(session_id): + """关闭会话""" + session = Session.query.get(session_id) + if not session: + return jsonify({'error': '会话不存在'}), 404 + + session.status = 'closed' + session.updated_at = datetime.utcnow() + db.session.commit() + + return jsonify({'success': True}) diff --git a/app/web/routes.py b/app/web/routes.py new file mode 100644 index 0000000..0f38a11 --- /dev/null +++ b/app/web/routes.py @@ -0,0 +1,59 @@ +""" +Web UI Blueprint +智队中枢 Web 管理界面 +""" +from flask import Blueprint, render_template, request, jsonify +from flask_jwt_extended import jwt_required, get_jwt_identity +from datetime import datetime, timedelta +from app.models import db, User, Session, Agent, Gateway, Message + +web_bp = Blueprint('web', __name__, url_prefix='/web') + + +@web_bp.route('/') +@jwt_required() +def index(): + """首页/仪表盘""" + # 统计数据 + stats = { + 'online_agents': Agent.query.filter_by(status='online').count(), + 'active_sessions': Session.query.filter_by(status='active').count(), + 'today_messages': Message.query.filter( + Message.created_at >= datetime.utcnow() - timedelta(days=1) + ).count(), + 'online_gateways': Gateway.query.filter_by(status='online').count(), + } + + # 最近会话 + recent_sessions = Session.query.order_by( + Session.last_active_at.desc() + ).limit(5).all() + + return render_template('index.html', stats=stats, recent_sessions=recent_sessions) + + +@web_bp.route('/config/channels') +@jwt_required() +def channels(): + """频道配置列表""" + from app.models.channel_config import ChannelConfig + configs = ChannelConfig.query.all() + return render_template('config/channels.html', configs=configs) + + +@web_bp.route('/test') +@jwt_required() +def test(): + """连接测试页面""" + from app.models.channel_config import ChannelConfig + configs = ChannelConfig.query.filter_by(enabled=True).all() + return render_template('test/index.html', configs=configs) + + +@web_bp.route('/monitor/sessions') +@jwt_required() +def sessions(): + """会话监控""" + agents = Agent.query.all() + sessions = Session.query.order_by(Session.last_active_at.desc()).limit(50).all() + return render_template('monitor/sessions.html', sessions=sessions, agents=agents)