remote-claude 0.1.0
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.
- package/.env.example +15 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/bin/cl +20 -0
- package/bin/cla +20 -0
- package/bin/remote-claude +21 -0
- package/client/client.py +251 -0
- package/lark_client/__init__.py +3 -0
- package/lark_client/capture_output.py +91 -0
- package/lark_client/card_builder.py +1114 -0
- package/lark_client/card_service.py +250 -0
- package/lark_client/config.py +22 -0
- package/lark_client/lark_handler.py +841 -0
- package/lark_client/main.py +306 -0
- package/lark_client/output_cleaner.py +222 -0
- package/lark_client/session_bridge.py +195 -0
- package/lark_client/shared_memory_poller.py +364 -0
- package/lark_client/terminal_buffer.py +215 -0
- package/lark_client/terminal_renderer.py +69 -0
- package/package.json +41 -0
- package/pyproject.toml +14 -0
- package/remote_claude.py +518 -0
- package/scripts/check-env.sh +40 -0
- package/scripts/completion.sh +76 -0
- package/scripts/postinstall.sh +76 -0
- package/server/component_parser.py +1113 -0
- package/server/rich_text_renderer.py +301 -0
- package/server/server.py +801 -0
- package/server/shared_state.py +198 -0
- package/stats/__init__.py +38 -0
- package/stats/collector.py +325 -0
- package/stats/machine.py +47 -0
- package/stats/query.py +151 -0
- package/utils/components.py +165 -0
- package/utils/protocol.py +164 -0
- package/utils/session.py +409 -0
- package/uv.lock +703 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Remote Claude 飞书客户端
|
|
4
|
+
|
|
5
|
+
通过飞书聊天控制 remote_claude 会话
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import lark_oapi as lark
|
|
16
|
+
|
|
17
|
+
# 在 SDK 配置 logging 之前,先设置根 logger 和我们自己模块的 DEBUG 级别
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.DEBUG,
|
|
20
|
+
format='[%(name)s] %(message)s',
|
|
21
|
+
)
|
|
22
|
+
# 将噪音较大的第三方库保持 INFO 级别
|
|
23
|
+
for _noisy in ('urllib3', 'websockets', 'asyncio'):
|
|
24
|
+
logging.getLogger(_noisy).setLevel(logging.INFO)
|
|
25
|
+
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
|
|
26
|
+
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
|
27
|
+
P2CardActionTrigger, P2CardActionTriggerResponse, CallBackToast
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from . import config
|
|
31
|
+
from .lark_handler import handler
|
|
32
|
+
|
|
33
|
+
def check_user_allowed(user_id: str) -> bool:
|
|
34
|
+
"""检查用户是否在白名单中"""
|
|
35
|
+
if not config.ENABLE_USER_WHITELIST:
|
|
36
|
+
return True
|
|
37
|
+
return user_id in config.ALLOWED_USERS
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def handle_message_receive(data: P2ImMessageReceiveV1) -> None:
|
|
41
|
+
"""处理收到的消息"""
|
|
42
|
+
try:
|
|
43
|
+
event = data.event
|
|
44
|
+
message = event.message
|
|
45
|
+
sender = event.sender
|
|
46
|
+
|
|
47
|
+
# 获取基本信息
|
|
48
|
+
user_id = sender.sender_id.open_id
|
|
49
|
+
chat_id = message.chat_id
|
|
50
|
+
message_type = message.message_type
|
|
51
|
+
chat_type = message.chat_type
|
|
52
|
+
|
|
53
|
+
# 检查用户白名单
|
|
54
|
+
if not check_user_allowed(user_id):
|
|
55
|
+
print(f"[Lark] 用户 {user_id} 不在白名单中")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# 只处理文本消息
|
|
59
|
+
if message_type != "text":
|
|
60
|
+
print(f"[Lark] 忽略非文本消息: {message_type}")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# 解析消息内容
|
|
64
|
+
content = json.loads(message.content)
|
|
65
|
+
text = content.get("text", "").strip()
|
|
66
|
+
|
|
67
|
+
# 移除 @ 提及
|
|
68
|
+
if message.mentions:
|
|
69
|
+
for mention in message.mentions:
|
|
70
|
+
text = text.replace(f"@_{mention.key}", "").strip()
|
|
71
|
+
text = text.replace(mention.key, "").strip()
|
|
72
|
+
|
|
73
|
+
if not text:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
print(f"[Lark] 收到消息: {user_id[:8]}... -> {text[:50]}...")
|
|
77
|
+
|
|
78
|
+
# 异步处理消息(传入 chat_type 以支持群聊路由)
|
|
79
|
+
asyncio.create_task(handler.handle_message(user_id, chat_id, text, chat_type=chat_type))
|
|
80
|
+
|
|
81
|
+
except Exception as e:
|
|
82
|
+
print(f"[Lark] 处理消息异常: {e}")
|
|
83
|
+
import traceback
|
|
84
|
+
traceback.print_exc()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerResponse:
|
|
88
|
+
"""处理卡片按钮点击"""
|
|
89
|
+
try:
|
|
90
|
+
action = event.event.action
|
|
91
|
+
operator = event.event.operator
|
|
92
|
+
context = event.event.context
|
|
93
|
+
|
|
94
|
+
user_id = operator.open_id
|
|
95
|
+
chat_id = context.open_chat_id
|
|
96
|
+
message_id = context.open_message_id # 原始卡片 message_id,用于就地更新
|
|
97
|
+
action_value = action.value or {}
|
|
98
|
+
|
|
99
|
+
print(f"[Lark] 收到卡片动作: user={user_id[:8]}..., action={action_value}")
|
|
100
|
+
|
|
101
|
+
# 检查用户白名单
|
|
102
|
+
if not check_user_allowed(user_id):
|
|
103
|
+
print(f"[Lark] 用户 {user_id} 不在白名单中")
|
|
104
|
+
toast = CallBackToast()
|
|
105
|
+
toast.type = "error"
|
|
106
|
+
toast.content = "您没有权限操作"
|
|
107
|
+
response = P2CardActionTriggerResponse()
|
|
108
|
+
response.toast = toast
|
|
109
|
+
return response
|
|
110
|
+
|
|
111
|
+
# 检测 form 提交(输入框 Enter ↵ 按钮)
|
|
112
|
+
form_value = getattr(action, 'form_value', None)
|
|
113
|
+
if form_value is not None:
|
|
114
|
+
command_text = (form_value.get("command") or "").strip()
|
|
115
|
+
print(f"[Lark] form 提交: user={user_id[:8]}..., command={command_text!r}")
|
|
116
|
+
if command_text:
|
|
117
|
+
# 有输入内容 → 直通 Claude
|
|
118
|
+
asyncio.create_task(handler.forward_to_claude(user_id, chat_id, command_text))
|
|
119
|
+
else:
|
|
120
|
+
# 空输入 → 发送原始 Enter 键(用于确认默认选项等场景)
|
|
121
|
+
asyncio.create_task(handler.send_raw_key(user_id, chat_id, "enter"))
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
action_type = action_value.get("action", "")
|
|
125
|
+
|
|
126
|
+
# 处理选项选择动作
|
|
127
|
+
if action_type == "select_option":
|
|
128
|
+
option_value = action_value.get("value", "")
|
|
129
|
+
option_total = int(action_value.get("total", "0"))
|
|
130
|
+
print(f"[Lark] 用户选择了选项: {option_value} (total={option_total})")
|
|
131
|
+
asyncio.create_task(handler.handle_option_select(user_id, chat_id, option_value, option_total))
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# 列表卡片:进入会话
|
|
135
|
+
if action_type == "list_attach":
|
|
136
|
+
session_name = action_value.get("session", "")
|
|
137
|
+
print(f"[Lark] list_attach: session={session_name}")
|
|
138
|
+
asyncio.create_task(handler._cmd_attach(user_id, chat_id, session_name, message_id=message_id))
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
# 列表卡片:断开连接
|
|
142
|
+
if action_type == "list_detach":
|
|
143
|
+
print(f"[Lark] list_detach: chat={chat_id[:8]}...")
|
|
144
|
+
asyncio.create_task(handler._handle_list_detach(user_id, chat_id, message_id=message_id))
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
# 列表卡片:创建群聊
|
|
148
|
+
if action_type == "list_new_group":
|
|
149
|
+
session_name = action_value.get("session", "")
|
|
150
|
+
print(f"[Lark] list_new_group: session={session_name}")
|
|
151
|
+
asyncio.create_task(handler._cmd_new_group(user_id, chat_id, session_name))
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
# 列表卡片:解散群聊
|
|
155
|
+
if action_type == "list_disband_group":
|
|
156
|
+
session_name = action_value.get("session", "")
|
|
157
|
+
print(f"[Lark] list_disband_group: session={session_name}")
|
|
158
|
+
asyncio.create_task(handler._cmd_disband_group(user_id, chat_id, session_name, message_id=message_id))
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
# 目录卡片:进入子目录(继续浏览,就地更新原卡片)
|
|
162
|
+
if action_type == "dir_browse":
|
|
163
|
+
path = action_value.get("path", "")
|
|
164
|
+
print(f"[Lark] dir_browse: path={path}")
|
|
165
|
+
asyncio.create_task(handler._cmd_ls(user_id, chat_id, path, message_id=message_id))
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
# 目录卡片:在该目录创建新 Claude 会话
|
|
169
|
+
if action_type == "dir_start":
|
|
170
|
+
path = action_value.get("path", "")
|
|
171
|
+
session_name = action_value.get("session_name", "")
|
|
172
|
+
print(f"[Lark] dir_start: path={path}, session={session_name}")
|
|
173
|
+
asyncio.create_task(handler._cmd_start(user_id, chat_id, f"{session_name} {path}"))
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
# 目录卡片:在该目录启动会话并创建专属群聊
|
|
177
|
+
if action_type == "dir_new_group":
|
|
178
|
+
path = action_value.get("path", "")
|
|
179
|
+
session_name = action_value.get("session_name", "")
|
|
180
|
+
print(f"[Lark] dir_new_group: path={path}, session={session_name}")
|
|
181
|
+
asyncio.create_task(handler._cmd_start_and_new_group(user_id, chat_id, session_name, path))
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# /menu 卡片按钮
|
|
185
|
+
if action_type == "menu_detach":
|
|
186
|
+
asyncio.create_task(handler._cmd_detach(user_id, chat_id, message_id=message_id))
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
if action_type == "menu_list":
|
|
190
|
+
asyncio.create_task(handler._cmd_list(user_id, chat_id, message_id=message_id))
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
if action_type == "menu_help":
|
|
194
|
+
asyncio.create_task(handler._cmd_help(user_id, chat_id, message_id=message_id))
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
if action_type == "menu_ls":
|
|
198
|
+
asyncio.create_task(handler._cmd_ls(user_id, chat_id, "", message_id=message_id))
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
if action_type == "menu_tree":
|
|
202
|
+
asyncio.create_task(handler._cmd_ls(user_id, chat_id, "", tree=True, message_id=message_id))
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
# 流式卡片:断开连接
|
|
206
|
+
if action_type == "stream_detach":
|
|
207
|
+
session_name = action_value.get("session", "")
|
|
208
|
+
print(f"[Lark] stream_detach: session={session_name}")
|
|
209
|
+
asyncio.create_task(handler._handle_stream_detach(user_id, chat_id, session_name, message_id=message_id))
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
# 流式卡片:重新连接
|
|
213
|
+
if action_type == "stream_reconnect":
|
|
214
|
+
session_name = action_value.get("session", "")
|
|
215
|
+
print(f"[Lark] stream_reconnect: session={session_name}")
|
|
216
|
+
asyncio.create_task(handler._handle_stream_reconnect(user_id, chat_id, session_name, message_id=message_id))
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# 快捷键按钮(callback 模式)
|
|
220
|
+
if action_type == "send_key":
|
|
221
|
+
key_name = action_value.get("key", "")
|
|
222
|
+
print(f"[Lark] send_key: key={key_name}")
|
|
223
|
+
asyncio.create_task(handler.send_raw_key(user_id, chat_id, key_name))
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
# 各卡片底部菜单按钮:辅助卡片就地→菜单,流式卡片降级新卡
|
|
227
|
+
if action_type == "menu_open":
|
|
228
|
+
asyncio.create_task(handler._cmd_menu(user_id, chat_id, message_id=message_id))
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
# 默认响应
|
|
232
|
+
return P2CardActionTriggerResponse()
|
|
233
|
+
|
|
234
|
+
except Exception as e:
|
|
235
|
+
print(f"[Lark] 处理卡片动作异常: {e}")
|
|
236
|
+
import traceback
|
|
237
|
+
traceback.print_exc()
|
|
238
|
+
return P2CardActionTriggerResponse()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class LarkBot:
|
|
242
|
+
"""飞书机器人"""
|
|
243
|
+
|
|
244
|
+
def __init__(self):
|
|
245
|
+
self.ws_client = None
|
|
246
|
+
self.running = False
|
|
247
|
+
|
|
248
|
+
def start(self):
|
|
249
|
+
"""启动机器人"""
|
|
250
|
+
# 检查配置
|
|
251
|
+
if not config.FEISHU_APP_ID or not config.FEISHU_APP_SECRET:
|
|
252
|
+
print("错误: 请配置 FEISHU_APP_ID 和 FEISHU_APP_SECRET")
|
|
253
|
+
print("在 .env 文件中添加:")
|
|
254
|
+
print(" FEISHU_APP_ID=your_app_id")
|
|
255
|
+
print(" FEISHU_APP_SECRET=your_app_secret")
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
print("=" * 50)
|
|
259
|
+
print("Remote Claude 飞书客户端")
|
|
260
|
+
print("=" * 50)
|
|
261
|
+
print(f"App ID: {config.FEISHU_APP_ID[:8]}...")
|
|
262
|
+
print(f"白名单: {'启用' if config.ENABLE_USER_WHITELIST else '禁用'}")
|
|
263
|
+
print("=" * 50)
|
|
264
|
+
|
|
265
|
+
# 设置信号处理
|
|
266
|
+
signal.signal(signal.SIGINT, self._signal_handler)
|
|
267
|
+
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
268
|
+
|
|
269
|
+
# 创建事件处理器
|
|
270
|
+
event_handler = lark.EventDispatcherHandler.builder("", "") \
|
|
271
|
+
.register_p2_im_message_receive_v1(handle_message_receive) \
|
|
272
|
+
.register_p2_card_action_trigger(handle_card_action) \
|
|
273
|
+
.build()
|
|
274
|
+
|
|
275
|
+
# 创建 WebSocket 客户端
|
|
276
|
+
self.ws_client = lark.ws.Client(
|
|
277
|
+
config.FEISHU_APP_ID,
|
|
278
|
+
config.FEISHU_APP_SECRET,
|
|
279
|
+
event_handler=event_handler,
|
|
280
|
+
log_level=lark.LogLevel.INFO,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
self.running = True
|
|
284
|
+
print("\n机器人已启动,等待消息...")
|
|
285
|
+
print("在飞书中发送 /help 查看使用说明\n")
|
|
286
|
+
|
|
287
|
+
# 启动 WebSocket(阻塞)
|
|
288
|
+
self.ws_client.start()
|
|
289
|
+
|
|
290
|
+
def _signal_handler(self, signum, frame):
|
|
291
|
+
"""处理退出信号"""
|
|
292
|
+
print("\n正在关闭...")
|
|
293
|
+
self.running = False
|
|
294
|
+
if self.ws_client:
|
|
295
|
+
# WebSocket 客户端没有 stop 方法,直接退出
|
|
296
|
+
sys.exit(0)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def main():
|
|
300
|
+
"""入口函数"""
|
|
301
|
+
bot = LarkBot()
|
|
302
|
+
bot.start()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__":
|
|
306
|
+
main()
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude CLI 输出清理器
|
|
3
|
+
|
|
4
|
+
策略:不模拟终端,直接从原始输出中提取有意义的文本
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OutputCleaner:
|
|
12
|
+
"""Claude CLI 输出清理器"""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._buffer = b""
|
|
16
|
+
self._last_meaningful_content = ""
|
|
17
|
+
self._user_input = ""
|
|
18
|
+
|
|
19
|
+
def feed(self, data: bytes) -> None:
|
|
20
|
+
"""喂入原始输出数据"""
|
|
21
|
+
self._buffer += data
|
|
22
|
+
|
|
23
|
+
def set_user_input(self, text: str) -> None:
|
|
24
|
+
"""设置用户输入(用于过滤回显)"""
|
|
25
|
+
self._user_input = text.strip()
|
|
26
|
+
|
|
27
|
+
def get_response(self) -> str:
|
|
28
|
+
"""获取清理后的响应内容"""
|
|
29
|
+
text = self._buffer.decode('utf-8', errors='replace')
|
|
30
|
+
return self._extract_response(text)
|
|
31
|
+
|
|
32
|
+
def clear(self) -> None:
|
|
33
|
+
"""清空缓冲区"""
|
|
34
|
+
self._buffer = b""
|
|
35
|
+
self._last_meaningful_content = ""
|
|
36
|
+
|
|
37
|
+
def _extract_response(self, text: str) -> str:
|
|
38
|
+
"""从原始输出中提取 Claude 的回复"""
|
|
39
|
+
# 1. 移除所有 ANSI 转义序列
|
|
40
|
+
text = self._strip_ansi(text)
|
|
41
|
+
|
|
42
|
+
# 2. 移除所有控制字符(保留换行和空格)
|
|
43
|
+
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
|
|
44
|
+
|
|
45
|
+
# 3. 处理换行
|
|
46
|
+
text = text.replace('\r\n', '\n').replace('\r', '\n')
|
|
47
|
+
|
|
48
|
+
# 4. 提取有意义的内容
|
|
49
|
+
lines = text.split('\n')
|
|
50
|
+
meaningful_lines = []
|
|
51
|
+
|
|
52
|
+
for line in lines:
|
|
53
|
+
cleaned = self._clean_line(line)
|
|
54
|
+
if cleaned:
|
|
55
|
+
meaningful_lines.append(cleaned)
|
|
56
|
+
|
|
57
|
+
# 5. 去重并合并
|
|
58
|
+
result = self._deduplicate_lines(meaningful_lines)
|
|
59
|
+
|
|
60
|
+
return result
|
|
61
|
+
|
|
62
|
+
def _strip_ansi(self, text: str) -> str:
|
|
63
|
+
"""移除所有 ANSI 转义序列"""
|
|
64
|
+
# CSI 序列: ESC [ ... 字母
|
|
65
|
+
text = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]', '', text)
|
|
66
|
+
# OSC 序列: ESC ] ... BEL
|
|
67
|
+
text = re.sub(r'\x1b\][^\x07]*\x07', '', text)
|
|
68
|
+
# 其他转义序列
|
|
69
|
+
text = re.sub(r'\x1b[^[\]a-zA-Z]*[a-zA-Z]', '', text)
|
|
70
|
+
return text
|
|
71
|
+
|
|
72
|
+
def _clean_line(self, line: str) -> str:
|
|
73
|
+
"""清理单行内容"""
|
|
74
|
+
stripped = line.strip()
|
|
75
|
+
|
|
76
|
+
if not stripped:
|
|
77
|
+
return ""
|
|
78
|
+
|
|
79
|
+
# 跳过边框和分隔线
|
|
80
|
+
if all(c in '─━═-–—│╭╮╰╯┌┐└┘├┤┬┴┼ ' for c in stripped):
|
|
81
|
+
return ""
|
|
82
|
+
if stripped.startswith(('╭', '╰', '│', '┌', '└', '├')):
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
# 跳过界面元素
|
|
86
|
+
skip_patterns = [
|
|
87
|
+
r'^Welcome to',
|
|
88
|
+
r'^Welcome back',
|
|
89
|
+
r'^Try\s*"',
|
|
90
|
+
r'esc to',
|
|
91
|
+
r'for shortcuts',
|
|
92
|
+
r'^\?.*shortcuts',
|
|
93
|
+
r'· thinking',
|
|
94
|
+
r'· Thinking',
|
|
95
|
+
]
|
|
96
|
+
for pattern in skip_patterns:
|
|
97
|
+
if re.search(pattern, stripped, re.IGNORECASE):
|
|
98
|
+
return ""
|
|
99
|
+
|
|
100
|
+
# 跳过动画文本
|
|
101
|
+
animation_words = ['evaporating', 'seasoning', 'fiddling', 'symbioting', 'thinking']
|
|
102
|
+
stripped_lower = stripped.lower()
|
|
103
|
+
for word in animation_words:
|
|
104
|
+
# 只跳过完整包含动画词的行
|
|
105
|
+
if word in stripped_lower and len(stripped) < 50:
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
# 跳过特殊符号行
|
|
109
|
+
if stripped in ['❯', '>', '$', '⏺', '·', '( )', ';', '()', '( · )', '( ·', '✻', '✳']:
|
|
110
|
+
return ""
|
|
111
|
+
if all(c in '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠐⠂✻✳ ' for c in stripped):
|
|
112
|
+
return ""
|
|
113
|
+
|
|
114
|
+
# 跳过状态标签
|
|
115
|
+
if stripped in ['问候', 'Basic Math', 'Greeting', 'Code', 'Analysis', 'Initial Greeting']:
|
|
116
|
+
return ""
|
|
117
|
+
|
|
118
|
+
# 跳过命令提示符行
|
|
119
|
+
if stripped.startswith('❯') or stripped.startswith('>'):
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
# 跳过包含大量 ─ 的行(分隔线)
|
|
123
|
+
if '─' in stripped and stripped.count('─') > 3:
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
# 跳过短的无意义行(动画碎片)
|
|
127
|
+
if len(stripped) <= 3 and not self._has_cjk(stripped):
|
|
128
|
+
return ""
|
|
129
|
+
|
|
130
|
+
# 跳过只有 ... 或省略号的行
|
|
131
|
+
if stripped in ['…', '...', '..', '.']:
|
|
132
|
+
return ""
|
|
133
|
+
|
|
134
|
+
# 跳过用户输入回显
|
|
135
|
+
if self._user_input and stripped == self._user_input:
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
# 提取⏺符号后的内容(这通常是 Claude 的实际回复)
|
|
139
|
+
if '⏺' in stripped:
|
|
140
|
+
parts = stripped.split('⏺', 1)
|
|
141
|
+
if len(parts) > 1:
|
|
142
|
+
return parts[1].strip()
|
|
143
|
+
|
|
144
|
+
return stripped
|
|
145
|
+
|
|
146
|
+
def _has_cjk(self, text: str) -> bool:
|
|
147
|
+
"""检查是否包含中日韩字符"""
|
|
148
|
+
for char in text:
|
|
149
|
+
if '\u4e00' <= char <= '\u9fff':
|
|
150
|
+
return True
|
|
151
|
+
if '\u3040' <= char <= '\u30ff':
|
|
152
|
+
return True
|
|
153
|
+
if '\uac00' <= char <= '\ud7a3':
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
def _deduplicate_lines(self, lines: List[str]) -> str:
|
|
158
|
+
"""去除重复行"""
|
|
159
|
+
if not lines:
|
|
160
|
+
return ""
|
|
161
|
+
|
|
162
|
+
# 去除连续重复
|
|
163
|
+
deduped = [lines[0]]
|
|
164
|
+
for line in lines[1:]:
|
|
165
|
+
if line != deduped[-1]:
|
|
166
|
+
deduped.append(line)
|
|
167
|
+
|
|
168
|
+
# 合并结果
|
|
169
|
+
result = '\n'.join(deduped)
|
|
170
|
+
|
|
171
|
+
# 清理多余空白
|
|
172
|
+
result = re.sub(r'\n{3,}', '\n\n', result)
|
|
173
|
+
result = re.sub(r' {2,}', ' ', result)
|
|
174
|
+
|
|
175
|
+
return result.strip()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_cleaner():
|
|
179
|
+
"""测试清理器"""
|
|
180
|
+
cleaner = OutputCleaner()
|
|
181
|
+
cleaner.set_user_input("hello")
|
|
182
|
+
|
|
183
|
+
# 模拟真实捕获的输出
|
|
184
|
+
raw_outputs = [
|
|
185
|
+
b'\x1b[?2026h\x1b[2K\x1b[G\x1b[1A\r\x1b[2C\x1b[2Ahello\r\x1b[2B',
|
|
186
|
+
b'\x1b]0;\xe2\x9c\xb3 Greeting\x07',
|
|
187
|
+
b'\x1b[?2026h\r\x1b[3A\x1b[48;2;55;55;55m\x1b[38;2;80;80;80m\xe2\x9d\xaf \x1b[38;2;255;255;255mhello \x1b[39m\x1b[49m',
|
|
188
|
+
b'\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\r\n\x1b[39m\x1b[22m \x1b[38;2;153;153;153m? for shortcuts\x1b[39m',
|
|
189
|
+
b'\x1b[?2026h\r\x1b[6A\x1b[38;2;255;255;255m\xe2\x8f\xba\x1b[1C\x1b[39m\xe4\xbd\xa0\xe5\xa5\xbd\xef\xbc\x81\xe6\x9c\x89\xe4\xbb\x80\xe4\xb9\x88\xe5\x8f\xaf\xe4\xbb\xa5\xe5\xb8\xae\xe4\xbd\xa0\xe7\x9a\x84\xe5\x90\x97\xef\xbc\x9f',
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
for data in raw_outputs:
|
|
193
|
+
cleaner.feed(data)
|
|
194
|
+
|
|
195
|
+
result = cleaner.get_response()
|
|
196
|
+
print(f"清理后的响应:\n'{result}'")
|
|
197
|
+
print()
|
|
198
|
+
|
|
199
|
+
# 验证
|
|
200
|
+
errors = []
|
|
201
|
+
if "你好!有什么可以帮你的吗?" not in result:
|
|
202
|
+
errors.append(f"期望包含回复")
|
|
203
|
+
if "shortcuts" in result:
|
|
204
|
+
errors.append(f"不应包含 'shortcuts'")
|
|
205
|
+
if "hello" in result.lower():
|
|
206
|
+
errors.append(f"不应包含用户输入 'hello'")
|
|
207
|
+
if "❯" in result:
|
|
208
|
+
errors.append(f"不应包含命令提示符 '❯'")
|
|
209
|
+
if "─" in result:
|
|
210
|
+
errors.append(f"不应包含分隔线")
|
|
211
|
+
|
|
212
|
+
if errors:
|
|
213
|
+
print("✗ 测试失败:")
|
|
214
|
+
for e in errors:
|
|
215
|
+
print(f" - {e}")
|
|
216
|
+
print(f"实际结果: '{result}'")
|
|
217
|
+
else:
|
|
218
|
+
print("✓ 测试通过")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
test_cleaner()
|