cloneloop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloneloop-0.1.0.dist-info/METADATA +392 -0
- cloneloop-0.1.0.dist-info/RECORD +118 -0
- cloneloop-0.1.0.dist-info/WHEEL +4 -0
- cloneloop-0.1.0.dist-info/entry_points.txt +5 -0
- mcp_ai_supervisor/__init__.py +52 -0
- mcp_ai_supervisor/__main__.py +551 -0
- mcp_ai_supervisor/debug.py +15 -0
- mcp_ai_supervisor/desktop_app/__init__.py +29 -0
- mcp_ai_supervisor/desktop_app/desktop_app.py +390 -0
- mcp_ai_supervisor/helpers/__init__.py +4 -0
- mcp_ai_supervisor/helpers/feedback_helpers.py +140 -0
- mcp_ai_supervisor/helpers/image_helpers.py +73 -0
- mcp_ai_supervisor/i18n.py +14 -0
- mcp_ai_supervisor/log_writer.py +16 -0
- mcp_ai_supervisor/logging/__init__.py +8 -0
- mcp_ai_supervisor/logging/log_parser.py +293 -0
- mcp_ai_supervisor/logging/log_tailer.py +367 -0
- mcp_ai_supervisor/py.typed +0 -0
- mcp_ai_supervisor/server.py +654 -0
- mcp_ai_supervisor/utils/__init__.py +28 -0
- mcp_ai_supervisor/utils/error_handler.py +10 -0
- mcp_ai_supervisor/utils/memory_monitor.py +11 -0
- mcp_ai_supervisor/utils/paths.py +7 -0
- mcp_ai_supervisor/utils/resource_manager.py +15 -0
- mcp_ai_supervisor/utils/structure_checker.py +291 -0
- mcp_ai_supervisor/web/__init__.py +26 -0
- mcp_ai_supervisor/web/constants/__init__.py +8 -0
- mcp_ai_supervisor/web/constants/message_codes.py +173 -0
- mcp_ai_supervisor/web/core/__init__.py +30 -0
- mcp_ai_supervisor/web/core/agent_bridge.py +957 -0
- mcp_ai_supervisor/web/core/auto_responder.py +332 -0
- mcp_ai_supervisor/web/core/context_collector.py +124 -0
- mcp_ai_supervisor/web/core/conversation_recorder.py +751 -0
- mcp_ai_supervisor/web/core/delegate_manager.py +1193 -0
- mcp_ai_supervisor/web/core/feedback_mediator.py +259 -0
- mcp_ai_supervisor/web/core/message_channel.py +551 -0
- mcp_ai_supervisor/web/core/response_builder.py +333 -0
- mcp_ai_supervisor/web/core/scene_detector.py +184 -0
- mcp_ai_supervisor/web/core/task_state.py +194 -0
- mcp_ai_supervisor/web/locales/en/translation.json +643 -0
- mcp_ai_supervisor/web/locales/zh-CN/translation.json +629 -0
- mcp_ai_supervisor/web/locales/zh-TW/translation.json +648 -0
- mcp_ai_supervisor/web/main.py +747 -0
- mcp_ai_supervisor/web/managers/__init__.py +8 -0
- mcp_ai_supervisor/web/managers/desktop_manager.py +228 -0
- mcp_ai_supervisor/web/managers/server_manager.py +170 -0
- mcp_ai_supervisor/web/managers/session_manager.py +515 -0
- mcp_ai_supervisor/web/managers/workbench_connector.py +186 -0
- mcp_ai_supervisor/web/models/__init__.py +13 -0
- mcp_ai_supervisor/web/models/feedback_result.py +16 -0
- mcp_ai_supervisor/web/models/feedback_session.py +938 -0
- mcp_ai_supervisor/web/models/session_cleaner.py +245 -0
- mcp_ai_supervisor/web/models/session_commands.py +213 -0
- mcp_ai_supervisor/web/models/session_resolver.py +158 -0
- mcp_ai_supervisor/web/models/session_timer.py +242 -0
- mcp_ai_supervisor/web/routes/__init__.py +12 -0
- mcp_ai_supervisor/web/routes/main_routes.py +965 -0
- mcp_ai_supervisor/web/routes/settings_routes.py +195 -0
- mcp_ai_supervisor/web/routes/ws_handlers.py +270 -0
- mcp_ai_supervisor/web/static/css/audio-management.css +545 -0
- mcp_ai_supervisor/web/static/css/notification-settings.css +152 -0
- mcp_ai_supervisor/web/static/css/prompt-management.css +566 -0
- mcp_ai_supervisor/web/static/css/session-management.css +1428 -0
- mcp_ai_supervisor/web/static/css/styles.css +2267 -0
- mcp_ai_supervisor/web/static/favicon.ico +0 -0
- mcp_ai_supervisor/web/static/icon-192.png +0 -0
- mcp_ai_supervisor/web/static/icon.svg +11 -0
- mcp_ai_supervisor/web/static/index.html +37 -0
- mcp_ai_supervisor/web/static/js/app.js +1721 -0
- mcp_ai_supervisor/web/static/js/i18n.js +376 -0
- mcp_ai_supervisor/web/static/js/modules/audio/audio-manager.js +610 -0
- mcp_ai_supervisor/web/static/js/modules/audio/audio-settings-ui.js +732 -0
- mcp_ai_supervisor/web/static/js/modules/connection-monitor.js +435 -0
- mcp_ai_supervisor/web/static/js/modules/constants/message-codes.js +168 -0
- mcp_ai_supervisor/web/static/js/modules/countdown-manager.js +273 -0
- mcp_ai_supervisor/web/static/js/modules/drag-drop-handler.js +677 -0
- mcp_ai_supervisor/web/static/js/modules/file-upload-manager.js +555 -0
- mcp_ai_supervisor/web/static/js/modules/image-handler.js +199 -0
- mcp_ai_supervisor/web/static/js/modules/logger.js +404 -0
- mcp_ai_supervisor/web/static/js/modules/notification/notification-manager.js +360 -0
- mcp_ai_supervisor/web/static/js/modules/notification/notification-settings.js +344 -0
- mcp_ai_supervisor/web/static/js/modules/prompt/prompt-input-buttons.js +427 -0
- mcp_ai_supervisor/web/static/js/modules/prompt/prompt-manager.js +414 -0
- mcp_ai_supervisor/web/static/js/modules/prompt/prompt-modal.js +458 -0
- mcp_ai_supervisor/web/static/js/modules/prompt/prompt-settings-ui.js +524 -0
- mcp_ai_supervisor/web/static/js/modules/session/session-data-manager.js +1042 -0
- mcp_ai_supervisor/web/static/js/modules/session/session-details-modal.js +594 -0
- mcp_ai_supervisor/web/static/js/modules/session/session-ui-renderer.js +836 -0
- mcp_ai_supervisor/web/static/js/modules/session-manager.js +1059 -0
- mcp_ai_supervisor/web/static/js/modules/settings-manager.js +1002 -0
- mcp_ai_supervisor/web/static/js/modules/tab-manager.js +235 -0
- mcp_ai_supervisor/web/static/js/modules/textarea-height-manager.js +267 -0
- mcp_ai_supervisor/web/static/js/modules/ui-manager.js +578 -0
- mcp_ai_supervisor/web/static/js/modules/utils/dom-utils.js +392 -0
- mcp_ai_supervisor/web/static/js/modules/utils/status-utils.js +403 -0
- mcp_ai_supervisor/web/static/js/modules/utils/time-utils.js +440 -0
- mcp_ai_supervisor/web/static/js/modules/utils.js +557 -0
- mcp_ai_supervisor/web/static/js/modules/websocket-manager.js +875 -0
- mcp_ai_supervisor/web/static/js/modules/workbench-notification.js +103 -0
- mcp_ai_supervisor/web/static/js/vendor/marked.min.js +6 -0
- mcp_ai_supervisor/web/static/js/vendor/purify.min.js +3 -0
- mcp_ai_supervisor/web/templates/components/image-upload.html +43 -0
- mcp_ai_supervisor/web/templates/components/settings-card.html +58 -0
- mcp_ai_supervisor/web/templates/components/status-indicator.html +31 -0
- mcp_ai_supervisor/web/templates/components/toggle-switch.html +19 -0
- mcp_ai_supervisor/web/templates/feedback.html +1198 -0
- mcp_ai_supervisor/web/templates/index.html +378 -0
- mcp_ai_supervisor/web/utils/__init__.py +12 -0
- mcp_ai_supervisor/web/utils/browser.py +35 -0
- mcp_ai_supervisor/web/utils/compression_config.py +195 -0
- mcp_ai_supervisor/web/utils/compression_monitor.py +314 -0
- mcp_ai_supervisor/web/utils/ide_opener.py +286 -0
- mcp_ai_supervisor/web/utils/network.py +66 -0
- mcp_ai_supervisor/web/utils/port_manager.py +340 -0
- mcp_ai_supervisor/web/utils/session_cleanup_manager.py +543 -0
- mcp_ai_supervisor/web/utils/workbench_client.py +899 -0
- mcp_ai_supervisor/workbench/__init__.py +5 -0
- mcp_ai_supervisor/workbench/__main__.py +5 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
日志解析模块 (LogParser)
|
|
3
|
+
========================
|
|
4
|
+
|
|
5
|
+
解析 LogWriter 生成的结构化日志文件,支持:
|
|
6
|
+
- 单行正则解析(匹配 LogWriter 的输出格式)
|
|
7
|
+
- 多行合并(堆栈跟踪续行归并到前一条日志)
|
|
8
|
+
- 级别过滤(DEBUG < INFO < WARN < ERROR)
|
|
9
|
+
- 分页查询(offset + limit)
|
|
10
|
+
- tail 模式(从文件末尾反向读取)
|
|
11
|
+
- 快速行数统计(不解析内容)
|
|
12
|
+
|
|
13
|
+
日志格式规范(与 LogWriter 一致):
|
|
14
|
+
{timestamp} [{level}] [{module}] [{context}] {message}
|
|
15
|
+
示例:2026-07-13T14:30:22.456 [INFO ] [feedback] [project=test] 消息内容
|
|
16
|
+
|
|
17
|
+
依赖:
|
|
18
|
+
- 无外部依赖,纯标准库实现
|
|
19
|
+
- 日志格式定义来自 L1 LogWriter
|
|
20
|
+
|
|
21
|
+
设计文档: docs/控制台方案/技术方案/日志系统/设计文档/日志查看器.md
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import NamedTuple
|
|
31
|
+
|
|
32
|
+
# ── 常量 ──────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
# 日志级别优先级映射(与 LogWriter 一致)
|
|
35
|
+
_LEVEL_PRIORITY = {
|
|
36
|
+
"DEBUG": 0,
|
|
37
|
+
"INFO": 1,
|
|
38
|
+
"WARN": 2,
|
|
39
|
+
"ERROR": 3,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# 预编译正则:匹配 LogWriter 的输出格式
|
|
43
|
+
# 格式: {timestamp} [{level}] [{module}] [{context}] {message}
|
|
44
|
+
# 示例: 2026-07-13T14:30:22.456 [INFO ] [feedback] [project=test] 消息内容
|
|
45
|
+
_LOG_LINE_RE = re.compile(
|
|
46
|
+
r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3})" # 时间戳
|
|
47
|
+
r"\s+\[(\w+)\s*\]" # 级别(可能有尾部空格)
|
|
48
|
+
r"\s+\[([^\]]*)\]" # 模块名(可为空)
|
|
49
|
+
r"\s+\[([^\]]*)\]" # 上下文(可为空)
|
|
50
|
+
r"\s+(.*)" # 消息(余下所有内容)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# 续行判断:以空格/Tab/Traceback 开头的行视为前一条的延续
|
|
54
|
+
_CONTINUATION_RE = re.compile(r"^[\s\t]|^Traceback ")
|
|
55
|
+
|
|
56
|
+
# 快速行统计的读取块大小
|
|
57
|
+
_COUNT_BLOCK_SIZE = 65536
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── 数据结构 ──────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class LogLine:
|
|
65
|
+
"""解析后的单条日志记录
|
|
66
|
+
|
|
67
|
+
Attributes:
|
|
68
|
+
line_number: 在原始文件中的行号(1-based)
|
|
69
|
+
timestamp: 原始时间戳字符串,如 "2026-07-13T14:30:22.456"
|
|
70
|
+
level: 日志级别 "DEBUG" | "INFO" | "WARN" | "ERROR"
|
|
71
|
+
module: 模块名,如 "feedback"、"workbench"
|
|
72
|
+
context: 原始上下文字符串,如 "project=test session=abc"
|
|
73
|
+
message: 消息正文(多行合并后可能包含换行符)
|
|
74
|
+
raw: 原始行文本(多行合并后包含所有原始行,换行连接)
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
line_number: int
|
|
78
|
+
timestamp: str
|
|
79
|
+
level: str
|
|
80
|
+
module: str
|
|
81
|
+
context: str
|
|
82
|
+
message: str
|
|
83
|
+
raw: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ParseResult(NamedTuple):
|
|
87
|
+
"""parse_lines() 的返回结果
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
lines: 解析后的日志行列表
|
|
91
|
+
total_lines: 文件总行数(原始行,未合并)
|
|
92
|
+
has_more: 是否还有更多数据可加载
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
lines: list[LogLine]
|
|
96
|
+
total_lines: int
|
|
97
|
+
has_more: bool
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── LogParser 核心类 ──────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class LogParser:
|
|
104
|
+
"""日志文件解析器
|
|
105
|
+
|
|
106
|
+
提供对 LogWriter 生成的日志文件的解析能力,
|
|
107
|
+
支持单行解析、多行合并、级别过滤、分页和 tail 模式。
|
|
108
|
+
|
|
109
|
+
使用方式:
|
|
110
|
+
parser = LogParser()
|
|
111
|
+
result = parser.parse_lines("/path/to/log.log", level="INFO", tail=True, limit=100)
|
|
112
|
+
for line in result.lines:
|
|
113
|
+
print(f"{line.timestamp} [{line.level}] {line.message}")
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
# 最大允许返回行数
|
|
117
|
+
MAX_LIMIT = 5000
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def parse_single_line(raw_line: str, line_number: int = 0) -> LogLine | None:
|
|
121
|
+
"""解析单行日志文本
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
raw_line: 原始日志行文本(不含末尾换行符)
|
|
125
|
+
line_number: 行号(1-based)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
解析成功返回 LogLine 对象,格式不匹配返回 None
|
|
129
|
+
"""
|
|
130
|
+
m = _LOG_LINE_RE.match(raw_line)
|
|
131
|
+
if not m:
|
|
132
|
+
return None
|
|
133
|
+
return LogLine(
|
|
134
|
+
line_number=line_number,
|
|
135
|
+
timestamp=m.group(1),
|
|
136
|
+
level=m.group(2).strip(),
|
|
137
|
+
module=m.group(3),
|
|
138
|
+
context=m.group(4),
|
|
139
|
+
message=m.group(5),
|
|
140
|
+
raw=raw_line,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def is_continuation(line: str) -> bool:
|
|
145
|
+
"""判断一行是否为续行(堆栈跟踪等)
|
|
146
|
+
|
|
147
|
+
续行规则:
|
|
148
|
+
- 以空格或 Tab 开头
|
|
149
|
+
- 以 "Traceback " 开头
|
|
150
|
+
"""
|
|
151
|
+
return bool(_CONTINUATION_RE.match(line))
|
|
152
|
+
|
|
153
|
+
def parse_lines(
|
|
154
|
+
self,
|
|
155
|
+
filepath: str | Path,
|
|
156
|
+
*,
|
|
157
|
+
offset: int = 0,
|
|
158
|
+
limit: int = 500,
|
|
159
|
+
level: str = "INFO",
|
|
160
|
+
tail: bool = False,
|
|
161
|
+
) -> ParseResult:
|
|
162
|
+
"""从日志文件读取、解析、过滤并分页返回
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
filepath: 日志文件路径
|
|
166
|
+
offset: 起始偏移(在过滤后的结果上应用)
|
|
167
|
+
limit: 返回行数上限(最大 MAX_LIMIT)
|
|
168
|
+
level: 最低日志级别过滤 "DEBUG" | "INFO" | "WARN" | "ERROR"
|
|
169
|
+
tail: True 时从文件末尾向前读取
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
ParseResult(lines, total_lines, has_more)
|
|
173
|
+
"""
|
|
174
|
+
filepath = Path(filepath)
|
|
175
|
+
if not filepath.exists() or not filepath.is_file():
|
|
176
|
+
return ParseResult(lines=[], total_lines=0, has_more=False)
|
|
177
|
+
|
|
178
|
+
limit = min(limit, self.MAX_LIMIT)
|
|
179
|
+
min_priority = _LEVEL_PRIORITY.get(level.upper(), 1)
|
|
180
|
+
|
|
181
|
+
# 读取并合并多行
|
|
182
|
+
merged = self._read_and_merge(filepath, tail=tail)
|
|
183
|
+
|
|
184
|
+
# 统计原始文件总行数
|
|
185
|
+
total_lines = self.total_lines(filepath)
|
|
186
|
+
|
|
187
|
+
# 级别过滤
|
|
188
|
+
filtered = [
|
|
189
|
+
ll for ll in merged
|
|
190
|
+
if _LEVEL_PRIORITY.get(ll.level, 0) >= min_priority
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
# 分页
|
|
194
|
+
total_filtered = len(filtered)
|
|
195
|
+
start = offset
|
|
196
|
+
end = offset + limit
|
|
197
|
+
page = filtered[start:end]
|
|
198
|
+
has_more = end < total_filtered
|
|
199
|
+
|
|
200
|
+
return ParseResult(lines=page, total_lines=total_lines, has_more=has_more)
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def total_lines(filepath: str | Path) -> int:
|
|
204
|
+
"""快速统计日志文件的总行数(不解析内容)
|
|
205
|
+
|
|
206
|
+
使用分块读取 + 统计换行符的方式,避免一次性加载整个文件。
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
filepath: 日志文件路径
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
文件总行数(0 表示空文件或文件不存在)
|
|
213
|
+
"""
|
|
214
|
+
filepath = Path(filepath)
|
|
215
|
+
if not filepath.exists() or not filepath.is_file():
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
count = 0
|
|
219
|
+
try:
|
|
220
|
+
with open(filepath, "rb") as f:
|
|
221
|
+
while True:
|
|
222
|
+
block = f.read(_COUNT_BLOCK_SIZE)
|
|
223
|
+
if not block:
|
|
224
|
+
break
|
|
225
|
+
count += block.count(b"\n")
|
|
226
|
+
except OSError:
|
|
227
|
+
return 0
|
|
228
|
+
return count
|
|
229
|
+
|
|
230
|
+
# ── 内部方法 ──────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
def _read_and_merge(
|
|
233
|
+
self, filepath: Path, *, tail: bool = False
|
|
234
|
+
) -> list[LogLine]:
|
|
235
|
+
"""读取日志文件并执行多行合并
|
|
236
|
+
|
|
237
|
+
多行合并规则:
|
|
238
|
+
- 以时间戳开头的行 → 新日志记录
|
|
239
|
+
- 续行(空格/Tab/Traceback 开头)→ 追加到前一条记录的 message
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
filepath: 日志文件路径
|
|
243
|
+
tail: True 时从文件末尾读取(先全部读入,结果保持时间顺序)
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
合并后的 LogLine 列表(按时间顺序排列)
|
|
247
|
+
"""
|
|
248
|
+
try:
|
|
249
|
+
text = filepath.read_text(encoding="utf-8", errors="replace")
|
|
250
|
+
except OSError:
|
|
251
|
+
return []
|
|
252
|
+
|
|
253
|
+
raw_lines = text.splitlines()
|
|
254
|
+
if not raw_lines:
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
result: list[LogLine] = []
|
|
258
|
+
current: LogLine | None = None
|
|
259
|
+
|
|
260
|
+
for idx, raw in enumerate(raw_lines, start=1):
|
|
261
|
+
if not raw.strip():
|
|
262
|
+
# 空行视为续行(如果有当前记录),否则跳过
|
|
263
|
+
if current is not None:
|
|
264
|
+
current.message += "\n"
|
|
265
|
+
current.raw += "\n"
|
|
266
|
+
continue
|
|
267
|
+
|
|
268
|
+
if self.is_continuation(raw):
|
|
269
|
+
# 续行 → 追加到当前记录
|
|
270
|
+
if current is not None:
|
|
271
|
+
current.message += "\n" + raw
|
|
272
|
+
current.raw += "\n" + raw
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
# 尝试解析为新日志行
|
|
276
|
+
parsed = self.parse_single_line(raw, line_number=idx)
|
|
277
|
+
if parsed is not None:
|
|
278
|
+
# 保存上一条,开始新记录
|
|
279
|
+
if current is not None:
|
|
280
|
+
result.append(current)
|
|
281
|
+
current = parsed
|
|
282
|
+
else:
|
|
283
|
+
# 格式不匹配 → 视为续行或跳过
|
|
284
|
+
if current is not None:
|
|
285
|
+
current.message += "\n" + raw
|
|
286
|
+
current.raw += "\n" + raw
|
|
287
|
+
# 无当前记录时忽略格式错误行
|
|
288
|
+
|
|
289
|
+
# 不要忘记最后一条
|
|
290
|
+
if current is not None:
|
|
291
|
+
result.append(current)
|
|
292
|
+
|
|
293
|
+
return result
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""
|
|
2
|
+
日志实时跟踪模块 (LogTailer)
|
|
3
|
+
============================
|
|
4
|
+
|
|
5
|
+
实现 tail -f 语义的日志文件实时跟踪,支持:
|
|
6
|
+
- 订阅/取消订阅机制(多 client 共享同一文件的读取)
|
|
7
|
+
- asyncio 定时轮询文件大小变化
|
|
8
|
+
- 新增内容通过 LogParser 解析后回调推送
|
|
9
|
+
- 级别过滤(仅推送 >= 指定级别的日志行)
|
|
10
|
+
- 批处理推送(100ms 内的多行合并为一次回调)
|
|
11
|
+
- 跨天文件切换(日期变更时自动切换到新文件)
|
|
12
|
+
- 背压保护(队列上限 + 回调超时防护)
|
|
13
|
+
|
|
14
|
+
依赖:
|
|
15
|
+
- L3 LogParser(日志行解析)
|
|
16
|
+
- asyncio(异步轮询循环)
|
|
17
|
+
|
|
18
|
+
设计文档: docs/控制台方案/技术方案/日志系统/设计文档/日志查看器.md
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
from collections import defaultdict
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Awaitable, Callable
|
|
30
|
+
|
|
31
|
+
from .log_parser import LogLine, LogParser
|
|
32
|
+
|
|
33
|
+
# ── 常量 ──────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
# 轮询间隔(秒)
|
|
36
|
+
DEFAULT_POLL_INTERVAL = 1.0
|
|
37
|
+
|
|
38
|
+
# 推送队列上限:超过此数量丢弃最旧的行
|
|
39
|
+
MAX_PENDING_LINES = 1000
|
|
40
|
+
|
|
41
|
+
# 回调超时(秒):防止慢回调阻塞轮询循环
|
|
42
|
+
CALLBACK_TIMEOUT = 5.0
|
|
43
|
+
|
|
44
|
+
# 日志级别优先级映射(与 LogParser 一致)
|
|
45
|
+
_LEVEL_PRIORITY = {
|
|
46
|
+
"DEBUG": 0,
|
|
47
|
+
"INFO": 1,
|
|
48
|
+
"WARN": 2,
|
|
49
|
+
"ERROR": 3,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── 数据结构 ──────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class TailSubscription:
|
|
58
|
+
"""单个客户端的订阅信息
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
client_id: 订阅者唯一标识
|
|
62
|
+
source: 日志源标识(如 "workbench"、"agent-xxx")
|
|
63
|
+
level: 最低日志级别过滤
|
|
64
|
+
callback: 推送回调函数,接收 list[LogLine]
|
|
65
|
+
file_path: 当前监控的日志文件路径
|
|
66
|
+
file_offset: 上次读取到的文件字节位置
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
client_id: str
|
|
70
|
+
source: str
|
|
71
|
+
level: str
|
|
72
|
+
callback: Callable[[list[LogLine]], Awaitable[None]]
|
|
73
|
+
file_path: Path
|
|
74
|
+
file_offset: int = 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── LogTailer 核心类 ──────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LogTailer:
|
|
81
|
+
"""日志文件实时跟踪器
|
|
82
|
+
|
|
83
|
+
通过 asyncio 定时轮询监控日志文件变化,
|
|
84
|
+
检测到新增内容后解析并推送给所有订阅者。
|
|
85
|
+
|
|
86
|
+
同一 source 的多个 client 共享一次文件读取,各自独立过滤级别。
|
|
87
|
+
|
|
88
|
+
使用方式:
|
|
89
|
+
tailer = LogTailer(log_base_dir=Path("~/.mcp-ai-supervisor/logs"))
|
|
90
|
+
tailer.subscribe("ws-client-1", "workbench", "INFO", my_callback)
|
|
91
|
+
await tailer.start()
|
|
92
|
+
# ... 当不再需要时 ...
|
|
93
|
+
await tailer.stop()
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
log_base_dir: Path | str | None = None,
|
|
99
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""初始化 LogTailer
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
log_base_dir: 日志根目录(默认 ~/.mcp-ai-supervisor/logs)
|
|
105
|
+
poll_interval: 轮询间隔(秒)
|
|
106
|
+
"""
|
|
107
|
+
if log_base_dir is None:
|
|
108
|
+
log_base_dir = Path.home() / ".mcp-ai-supervisor" / "logs"
|
|
109
|
+
self._log_base_dir = Path(log_base_dir)
|
|
110
|
+
self._poll_interval = poll_interval
|
|
111
|
+
self._subscriptions: dict[str, TailSubscription] = {}
|
|
112
|
+
self._poll_task: asyncio.Task[None] | None = None
|
|
113
|
+
self._parser = LogParser()
|
|
114
|
+
self._running = False
|
|
115
|
+
# 按 source 分组的文件偏移量缓存(避免同一文件重复读取)
|
|
116
|
+
self._source_offsets: dict[str, int] = {}
|
|
117
|
+
|
|
118
|
+
# ── 公开接口 ──────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def subscribe(
|
|
121
|
+
self,
|
|
122
|
+
client_id: str,
|
|
123
|
+
source: str,
|
|
124
|
+
level: str,
|
|
125
|
+
callback: Callable[[list[LogLine]], Awaitable[None]],
|
|
126
|
+
) -> None:
|
|
127
|
+
"""订阅日志源的实时推送
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
client_id: 订阅者唯一标识
|
|
131
|
+
source: 日志源(如 "workbench"、"agent-xxx")
|
|
132
|
+
level: 最低日志级别 "DEBUG" | "INFO" | "WARN" | "ERROR"
|
|
133
|
+
callback: 推送回调,接收解析后的日志行列表
|
|
134
|
+
"""
|
|
135
|
+
file_path = self._resolve_log_path(source)
|
|
136
|
+
offset = 0
|
|
137
|
+
if file_path.exists():
|
|
138
|
+
offset = file_path.stat().st_size
|
|
139
|
+
|
|
140
|
+
sub = TailSubscription(
|
|
141
|
+
client_id=client_id,
|
|
142
|
+
source=source,
|
|
143
|
+
level=level.upper(),
|
|
144
|
+
callback=callback,
|
|
145
|
+
file_path=file_path,
|
|
146
|
+
file_offset=offset,
|
|
147
|
+
)
|
|
148
|
+
self._subscriptions[client_id] = sub
|
|
149
|
+
|
|
150
|
+
def unsubscribe(self, client_id: str) -> None:
|
|
151
|
+
"""取消订阅
|
|
152
|
+
|
|
153
|
+
如果某个 source 没有订阅者了,其文件偏移缓存也被清除。
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
client_id: 订阅者唯一标识
|
|
157
|
+
"""
|
|
158
|
+
sub = self._subscriptions.pop(client_id, None)
|
|
159
|
+
if sub is None:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# 检查该 source 是否还有其他订阅者
|
|
163
|
+
remaining = any(
|
|
164
|
+
s.source == sub.source for s in self._subscriptions.values()
|
|
165
|
+
)
|
|
166
|
+
if not remaining:
|
|
167
|
+
self._source_offsets.pop(sub.source, None)
|
|
168
|
+
|
|
169
|
+
async def start(self) -> None:
|
|
170
|
+
"""启动轮询循环
|
|
171
|
+
|
|
172
|
+
可在 subscribe() 之前或之后调用。
|
|
173
|
+
重复调用 start() 不会创建多个轮询任务。
|
|
174
|
+
"""
|
|
175
|
+
if self._running:
|
|
176
|
+
return
|
|
177
|
+
self._running = True
|
|
178
|
+
self._poll_task = asyncio.create_task(self._poll_loop())
|
|
179
|
+
|
|
180
|
+
async def stop(self) -> None:
|
|
181
|
+
"""停止轮询循环
|
|
182
|
+
|
|
183
|
+
stop() 后可以再次调用 start() 重启。
|
|
184
|
+
"""
|
|
185
|
+
self._running = False
|
|
186
|
+
if self._poll_task is not None:
|
|
187
|
+
self._poll_task.cancel()
|
|
188
|
+
try:
|
|
189
|
+
await self._poll_task
|
|
190
|
+
except asyncio.CancelledError:
|
|
191
|
+
pass
|
|
192
|
+
self._poll_task = None
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def is_running(self) -> bool:
|
|
196
|
+
"""是否正在运行"""
|
|
197
|
+
return self._running
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def subscription_count(self) -> int:
|
|
201
|
+
"""当前订阅者数量"""
|
|
202
|
+
return len(self._subscriptions)
|
|
203
|
+
|
|
204
|
+
def get_subscribed_sources(self) -> set[str]:
|
|
205
|
+
"""获取所有被订阅的 source 集合"""
|
|
206
|
+
return {s.source for s in self._subscriptions.values()}
|
|
207
|
+
|
|
208
|
+
# ── 内部方法 ──────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
async def _poll_loop(self) -> None:
|
|
211
|
+
"""轮询循环主体
|
|
212
|
+
|
|
213
|
+
每隔 poll_interval 秒检查所有被订阅文件的大小变化,
|
|
214
|
+
检测到新增内容后解析并推送给订阅者。
|
|
215
|
+
"""
|
|
216
|
+
while self._running:
|
|
217
|
+
try:
|
|
218
|
+
if self._subscriptions:
|
|
219
|
+
await self._check_all_sources()
|
|
220
|
+
await asyncio.sleep(self._poll_interval)
|
|
221
|
+
except asyncio.CancelledError:
|
|
222
|
+
break
|
|
223
|
+
except Exception:
|
|
224
|
+
# 轮询循环中的错误不应导致整个 tailer 停止
|
|
225
|
+
await asyncio.sleep(self._poll_interval)
|
|
226
|
+
|
|
227
|
+
async def _check_all_sources(self) -> None:
|
|
228
|
+
"""检查所有被订阅 source 的文件变化
|
|
229
|
+
|
|
230
|
+
同一 source 的多个 client 只读取文件一次,各自独立过滤级别。
|
|
231
|
+
"""
|
|
232
|
+
# 按 source 分组订阅者
|
|
233
|
+
source_groups: dict[str, list[TailSubscription]] = defaultdict(list)
|
|
234
|
+
for sub in list(self._subscriptions.values()):
|
|
235
|
+
# 跨天文件切换:检查日期是否变化
|
|
236
|
+
current_path = self._resolve_log_path(sub.source)
|
|
237
|
+
if current_path != sub.file_path:
|
|
238
|
+
sub.file_path = current_path
|
|
239
|
+
sub.file_offset = 0
|
|
240
|
+
source_groups[sub.source].append(sub)
|
|
241
|
+
|
|
242
|
+
for source, subs in source_groups.items():
|
|
243
|
+
file_path = subs[0].file_path
|
|
244
|
+
if not file_path.exists():
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
current_size = file_path.stat().st_size
|
|
249
|
+
except OSError:
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
# 取该 source 所有 client 中最小的 offset
|
|
253
|
+
min_offset = min(s.file_offset for s in subs)
|
|
254
|
+
|
|
255
|
+
if current_size <= min_offset:
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
# 读取新增内容
|
|
259
|
+
new_lines = self._read_new_content(file_path, min_offset, current_size)
|
|
260
|
+
if not new_lines:
|
|
261
|
+
# 更新 offset 即使没有解析出有效行
|
|
262
|
+
for sub in subs:
|
|
263
|
+
sub.file_offset = current_size
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# 为每个 client 按级别过滤后推送
|
|
267
|
+
for sub in subs:
|
|
268
|
+
min_priority = _LEVEL_PRIORITY.get(sub.level, 1)
|
|
269
|
+
filtered = [
|
|
270
|
+
ll for ll in new_lines
|
|
271
|
+
if _LEVEL_PRIORITY.get(ll.level, 0) >= min_priority
|
|
272
|
+
]
|
|
273
|
+
if filtered:
|
|
274
|
+
# 背压保护:限制推送数量
|
|
275
|
+
if len(filtered) > MAX_PENDING_LINES:
|
|
276
|
+
filtered = filtered[-MAX_PENDING_LINES:]
|
|
277
|
+
|
|
278
|
+
await self._safe_callback(sub, filtered)
|
|
279
|
+
sub.file_offset = current_size
|
|
280
|
+
|
|
281
|
+
def _read_new_content(
|
|
282
|
+
self, file_path: Path, offset: int, current_size: int
|
|
283
|
+
) -> list[LogLine]:
|
|
284
|
+
"""读取文件新增部分并解析为 LogLine 列表
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
file_path: 日志文件路径
|
|
288
|
+
offset: 上次读取到的字节位置
|
|
289
|
+
current_size: 当前文件大小
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
解析后的日志行列表
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
|
|
296
|
+
f.seek(offset)
|
|
297
|
+
new_text = f.read(current_size - offset)
|
|
298
|
+
except OSError:
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
if not new_text:
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
raw_lines = new_text.splitlines()
|
|
305
|
+
result: list[LogLine] = []
|
|
306
|
+
current: LogLine | None = None
|
|
307
|
+
|
|
308
|
+
for raw in raw_lines:
|
|
309
|
+
if not raw.strip():
|
|
310
|
+
if current is not None:
|
|
311
|
+
current.message += "\n"
|
|
312
|
+
current.raw += "\n"
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
if self._parser.is_continuation(raw):
|
|
316
|
+
if current is not None:
|
|
317
|
+
current.message += "\n" + raw
|
|
318
|
+
current.raw += "\n" + raw
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
parsed = self._parser.parse_single_line(raw)
|
|
322
|
+
if parsed is not None:
|
|
323
|
+
if current is not None:
|
|
324
|
+
result.append(current)
|
|
325
|
+
current = parsed
|
|
326
|
+
else:
|
|
327
|
+
if current is not None:
|
|
328
|
+
current.message += "\n" + raw
|
|
329
|
+
current.raw += "\n" + raw
|
|
330
|
+
|
|
331
|
+
if current is not None:
|
|
332
|
+
result.append(current)
|
|
333
|
+
|
|
334
|
+
return result
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
async def _safe_callback(
|
|
338
|
+
sub: TailSubscription, lines: list[LogLine]
|
|
339
|
+
) -> None:
|
|
340
|
+
"""安全调用推送回调 — 超时和异常不影响主循环
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
sub: 订阅信息
|
|
344
|
+
lines: 要推送的日志行列表
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
await asyncio.wait_for(
|
|
348
|
+
sub.callback(lines), timeout=CALLBACK_TIMEOUT
|
|
349
|
+
)
|
|
350
|
+
except asyncio.TimeoutError:
|
|
351
|
+
pass
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
def _resolve_log_path(self, source: str) -> Path:
|
|
356
|
+
"""根据 source 和当前日期推算日志文件路径
|
|
357
|
+
|
|
358
|
+
路径格式: {log_base_dir}/{source}/{YYYY-MM-DD}.log
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
source: 日志源标识
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
日志文件的完整路径
|
|
365
|
+
"""
|
|
366
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
367
|
+
return self._log_base_dir / source / f"{today}.log"
|
|
File without changes
|