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
package/stats/query.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
StatsQuery:本地统计查询 + CLI 格式化输出
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
_DB_PATH = Path.home() / ".local" / "share" / "remote-claude" / "stats.db"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_conn() -> Optional[sqlite3.Connection]:
|
|
14
|
+
if not _DB_PATH.exists():
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
return sqlite3.connect(str(_DB_PATH))
|
|
18
|
+
except Exception:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _date_range(range_str: str) -> tuple[str, str]:
|
|
23
|
+
"""解析 range 字符串,返回 (start_date, end_date)"""
|
|
24
|
+
end_date = time.strftime('%Y-%m-%d')
|
|
25
|
+
if range_str == 'today' or range_str is None:
|
|
26
|
+
start_date = end_date
|
|
27
|
+
elif range_str.endswith('d'):
|
|
28
|
+
days = int(range_str[:-1])
|
|
29
|
+
start_date = time.strftime(
|
|
30
|
+
'%Y-%m-%d', time.localtime(time.time() - days * 86400)
|
|
31
|
+
)
|
|
32
|
+
elif range_str.endswith('m'):
|
|
33
|
+
months = int(range_str[:-1])
|
|
34
|
+
start_date = time.strftime(
|
|
35
|
+
'%Y-%m-%d', time.localtime(time.time() - months * 30 * 86400)
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
start_date = end_date
|
|
39
|
+
return start_date, end_date
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fmt_num(n: int) -> str:
|
|
43
|
+
"""格式化数字(千分位逗号)"""
|
|
44
|
+
return f"{n:,}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def query_summary(range_str: str = 'today', session_name: str = '',
|
|
48
|
+
detail: bool = False) -> str:
|
|
49
|
+
"""生成统计摘要字符串"""
|
|
50
|
+
conn = _get_conn()
|
|
51
|
+
if conn is None:
|
|
52
|
+
return "暂无统计数据(数据库未初始化)"
|
|
53
|
+
|
|
54
|
+
start_date, end_date = _date_range(range_str)
|
|
55
|
+
|
|
56
|
+
# 构建查询条件
|
|
57
|
+
where = "WHERE date >= ? AND date <= ?"
|
|
58
|
+
params: list = [start_date, end_date]
|
|
59
|
+
if session_name:
|
|
60
|
+
where += " AND session_name = ?"
|
|
61
|
+
params.append(session_name)
|
|
62
|
+
|
|
63
|
+
def _sum(category: str, event: str) -> int:
|
|
64
|
+
row = conn.execute(
|
|
65
|
+
f"SELECT COALESCE(SUM(value), 0) FROM events "
|
|
66
|
+
f"{where} AND category=? AND event=?",
|
|
67
|
+
params + [category, event]
|
|
68
|
+
).fetchone()
|
|
69
|
+
return row[0] if row else 0
|
|
70
|
+
|
|
71
|
+
def _count(category: str, event: str) -> int:
|
|
72
|
+
row = conn.execute(
|
|
73
|
+
f"SELECT COUNT(*) FROM events "
|
|
74
|
+
f"{where} AND category=? AND event=?",
|
|
75
|
+
params + [category, event]
|
|
76
|
+
).fetchone()
|
|
77
|
+
return row[0] if row else 0
|
|
78
|
+
|
|
79
|
+
# 收集各类指标
|
|
80
|
+
sess_start = _count('session', 'start')
|
|
81
|
+
sess_attach = _count('session', 'attach')
|
|
82
|
+
sess_end = _count('session', 'end')
|
|
83
|
+
|
|
84
|
+
term_connect = _count('terminal', 'connect')
|
|
85
|
+
term_input = _count('terminal', 'input')
|
|
86
|
+
term_disconnect = _count('terminal', 'disconnect')
|
|
87
|
+
|
|
88
|
+
lark_msg = _count('lark', 'message')
|
|
89
|
+
lark_opt = _count('lark', 'option_select')
|
|
90
|
+
lark_key = _count('lark', 'raw_key')
|
|
91
|
+
lark_cmd = _count('lark', 'cmd')
|
|
92
|
+
lark_attach = _count('lark', 'attach')
|
|
93
|
+
lark_cmd_start = _count('lark', 'cmd_start')
|
|
94
|
+
|
|
95
|
+
card_create = _count('card', 'create')
|
|
96
|
+
card_update = _count('card', 'update')
|
|
97
|
+
card_freeze = _count('card', 'freeze')
|
|
98
|
+
card_fallback = _count('card', 'fallback')
|
|
99
|
+
|
|
100
|
+
token_total = _sum('token', 'usage')
|
|
101
|
+
|
|
102
|
+
err_card = _count('error', 'card_api')
|
|
103
|
+
err_bridge = _count('error', 'bridge_send')
|
|
104
|
+
err_total = err_card + err_bridge
|
|
105
|
+
card_ops = card_create + card_update
|
|
106
|
+
err_rate = f"{err_total / card_ops * 100:.2f}%" if card_ops else "0%"
|
|
107
|
+
|
|
108
|
+
# 构建输出
|
|
109
|
+
if start_date == end_date:
|
|
110
|
+
date_label = start_date
|
|
111
|
+
else:
|
|
112
|
+
date_label = f"{start_date} ~ {end_date}"
|
|
113
|
+
|
|
114
|
+
lines = [
|
|
115
|
+
f"━━━━ Remote Claude 使用统计 ({date_label}) ━━━━",
|
|
116
|
+
"",
|
|
117
|
+
f"会话: 启动 {_fmt_num(sess_start)} | 连接 {_fmt_num(sess_attach + term_connect + lark_attach)} | 结束 {_fmt_num(sess_end)}",
|
|
118
|
+
f"终端: 输入 {_fmt_num(term_input)} 次 | 连接 {_fmt_num(term_connect)} 次",
|
|
119
|
+
f"飞书: 消息 {_fmt_num(lark_msg)} | 选项 {_fmt_num(lark_opt)} | 快捷键 {_fmt_num(lark_key)} | 命令 {_fmt_num(lark_cmd)}",
|
|
120
|
+
f"卡片: 创建 {_fmt_num(card_create)} | 更新 {_fmt_num(card_update)} | 冻结 {_fmt_num(card_freeze)}",
|
|
121
|
+
f"Token: ~{token_total / 1000:.1f}k",
|
|
122
|
+
f"错误: API {_fmt_num(err_card)} ({err_rate})",
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
if detail:
|
|
126
|
+
lines += [
|
|
127
|
+
"",
|
|
128
|
+
"── 详细分类 ──",
|
|
129
|
+
f"飞书会话: cmd_start {_fmt_num(lark_cmd_start)} | attach {_fmt_num(lark_attach)}",
|
|
130
|
+
f"卡片降级: {_fmt_num(card_fallback)}",
|
|
131
|
+
f"桥接错误: {_fmt_num(err_bridge)}",
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
conn.close()
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def reset_stats() -> str:
|
|
139
|
+
"""清空所有统计数据"""
|
|
140
|
+
conn = _get_conn()
|
|
141
|
+
if conn is None:
|
|
142
|
+
return "暂无统计数据"
|
|
143
|
+
try:
|
|
144
|
+
conn.execute("DELETE FROM events")
|
|
145
|
+
conn.execute("DELETE FROM daily_summary")
|
|
146
|
+
conn.execute("DELETE FROM meta WHERE key != 'first_run'")
|
|
147
|
+
conn.commit()
|
|
148
|
+
conn.close()
|
|
149
|
+
return "统计数据已清空"
|
|
150
|
+
except Exception as e:
|
|
151
|
+
return f"清空失败: {e}"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude CLI 输出组件模型
|
|
3
|
+
|
|
4
|
+
将 Claude 终端输出解析为结构化组件,供 CardBuilder 渲染为飞书卡片。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import List, Optional, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class OutputBlock:
|
|
13
|
+
"""统一输出块(文本回复、工具调用、Agent/Plan 块的统一表示)
|
|
14
|
+
|
|
15
|
+
parser 不区分三者,统一产出此类型。content 为圆点字符之后的内容(已去掉首列圆点)。
|
|
16
|
+
card_builder 可根据首行内容格式决定渲染方式。
|
|
17
|
+
"""
|
|
18
|
+
content: str # 完整块内容(首行去掉圆点,后续行原样,\n 连接)
|
|
19
|
+
is_streaming: bool = False # 首行首列圆点是否 blink
|
|
20
|
+
start_row: int = -1 # 在终端屏幕中的起始行号(用于帧间同一 Block 识别,-1 表示未知)
|
|
21
|
+
ansi_content: str = "" # 对应 content,带 ANSI 转义码
|
|
22
|
+
indicator: str = "" # 首列圆点字符原文(如 ●)
|
|
23
|
+
ansi_indicator: str = "" # 带 ANSI 颜色的圆点字符
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TextBlock:
|
|
28
|
+
"""文本回复(保留作为向后兼容,新代码请使用 OutputBlock)"""
|
|
29
|
+
content: str
|
|
30
|
+
is_streaming: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class UserInput:
|
|
35
|
+
"""用户输入(❯ 行)"""
|
|
36
|
+
text: str
|
|
37
|
+
ansi_text: str = "" # 对应 text,带 ANSI 转义码
|
|
38
|
+
indicator: str = "" # 首列提示符原文(❯)
|
|
39
|
+
ansi_indicator: str = "" # 带 ANSI 颜色的提示符
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolCall:
|
|
44
|
+
"""工具调用块(保留作为向后兼容,新代码请使用 OutputBlock)"""
|
|
45
|
+
tool_name: str
|
|
46
|
+
args_summary: str
|
|
47
|
+
status: str = "running"
|
|
48
|
+
status_detail: str = ""
|
|
49
|
+
output: str = ""
|
|
50
|
+
is_streaming: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class AgentBlock:
|
|
55
|
+
"""Agent/Plan 块(保留作为向后兼容,新代码请使用 OutputBlock)"""
|
|
56
|
+
agent_type: str
|
|
57
|
+
description: str
|
|
58
|
+
status: str = "running"
|
|
59
|
+
status_detail: str = ""
|
|
60
|
+
stats: str = ""
|
|
61
|
+
sub_calls: List[str] = field(default_factory=list)
|
|
62
|
+
is_streaming: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class OptionBlock:
|
|
67
|
+
"""选项交互块(统一 option + permission 两种场景)
|
|
68
|
+
|
|
69
|
+
状态型组件:全局唯一,存储在 ClaudeWindow.option_block,不进入 blocks 累积列表。
|
|
70
|
+
- sub_type="option":AskUserQuestion 选项(2 分割线 input_rows 中检测到编号选项)
|
|
71
|
+
- sub_type="permission":权限确认(1 分割线 bottom_rows 中检测到编号选项)
|
|
72
|
+
"""
|
|
73
|
+
sub_type: str = "option" # "option" | "permission"
|
|
74
|
+
tag: str = "" # 分类标签(option 场景)
|
|
75
|
+
title: str = "" # 工具名称(permission 场景)
|
|
76
|
+
content: str = "" # 详细内容(permission 场景)
|
|
77
|
+
question: str = "" # 问题文本
|
|
78
|
+
options: List[dict] = field(default_factory=list) # [{"label": str, "value": str}]
|
|
79
|
+
ansi_raw: str = "" # 整个选项区域的 ANSI 原始文本
|
|
80
|
+
indicator: str = "" # 首列字符原文
|
|
81
|
+
ansi_indicator: str = "" # 带 ANSI 颜色的首列字符
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# 向后兼容别名
|
|
85
|
+
PermissionBlock = OptionBlock
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class StatusLine:
|
|
90
|
+
"""状态行(✱ 动作... (时间 · tokens))"""
|
|
91
|
+
action: str # 动作动词(如 "Germinating...")
|
|
92
|
+
elapsed: str = "" # 时长(如 "16m 33s")
|
|
93
|
+
tokens: str = "" # token 消耗(如 "↓ 4.3k tokens")
|
|
94
|
+
raw: str = "" # 原始文本
|
|
95
|
+
ansi_raw: str = "" # 对应 raw,带 ANSI 转义码
|
|
96
|
+
indicator: str = "" # 首列星星字符原文(如 ✱)
|
|
97
|
+
ansi_indicator: str = "" # 带 ANSI 颜色的星星字符
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Divider:
|
|
102
|
+
"""水平分割线(区域边界标记,不渲染)
|
|
103
|
+
|
|
104
|
+
Claude CLI 终端有 2 条由 ─ 组成的分割线,将屏幕分为 3 个区域:
|
|
105
|
+
输出区(上)| ─── | 用户输入框(中)| ─── | 底部栏(下)
|
|
106
|
+
解析时用于定位区域边界,不作为卡片内容渲染。
|
|
107
|
+
"""
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class BottomBar:
|
|
113
|
+
"""底部栏(权限模式、后台任务等状态信息)
|
|
114
|
+
|
|
115
|
+
固定在终端最底部的状态栏,内容如:
|
|
116
|
+
- ▶▶ bypass permissions on (shift+tab to cycle) · esc to interrupt
|
|
117
|
+
- 2 bashes · ↓ to manage
|
|
118
|
+
- 4 local agents · ↓ to manage · ctrl+f to kill agents
|
|
119
|
+
"""
|
|
120
|
+
text: str # 原始文本内容
|
|
121
|
+
ansi_text: str = "" # 对应 text,带 ANSI 转义码
|
|
122
|
+
has_background_agents: bool = False # 底部栏是否包含后台 agent 信息
|
|
123
|
+
agent_count: int = 0 # 后台 agent 数量
|
|
124
|
+
agent_summary: str = "" # agent 摘要文本(如 "4 local agents")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class AgentPanelBlock:
|
|
129
|
+
"""Agent 管理面板(用户按 ↓ 展开的列表或 Enter 查看的详情)
|
|
130
|
+
|
|
131
|
+
出现在只有 1 条分割线的特殊布局中(输入区消失)。
|
|
132
|
+
列表模式:显示所有后台 agent 及其状态
|
|
133
|
+
详情模式:显示单个 agent 的详细信息
|
|
134
|
+
"""
|
|
135
|
+
panel_type: str = "list" # "list" | "detail"
|
|
136
|
+
# 列表模式字段
|
|
137
|
+
agent_count: int = 0
|
|
138
|
+
agents: List[dict] = field(default_factory=list) # [{"name": str, "status": str, "is_selected": bool}]
|
|
139
|
+
# 详情模式字段
|
|
140
|
+
agent_name: str = ""
|
|
141
|
+
agent_type: str = ""
|
|
142
|
+
stats: str = ""
|
|
143
|
+
progress: str = ""
|
|
144
|
+
prompt: str = ""
|
|
145
|
+
# 通用字段
|
|
146
|
+
raw_text: str = ""
|
|
147
|
+
ansi_raw: str = ""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class PlanBlock:
|
|
152
|
+
"""计划块(╭│╰ box-drawing 框线包裹的结构化计划内容)
|
|
153
|
+
|
|
154
|
+
累积型 Block:出现在输出区,保留在 blocks 历史列表中。
|
|
155
|
+
Claude CLI Plan Mode 显示的计划内容。
|
|
156
|
+
"""
|
|
157
|
+
title: str # 框内第一行非空文本(如 "Plan to implement")
|
|
158
|
+
content: str # 框内完整内容(去掉边框字符,\\n 连接)
|
|
159
|
+
is_streaming: bool = False # 始终为 False(╭ 不 blink)
|
|
160
|
+
start_row: int = -1
|
|
161
|
+
ansi_content: str = ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# 所有组件类型的联合类型
|
|
165
|
+
Component = Union[OutputBlock, TextBlock, UserInput, ToolCall, AgentBlock, OptionBlock, PermissionBlock, StatusLine, Divider, BottomBar, AgentPanelBlock, PlanBlock]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
通信协议定义
|
|
3
|
+
|
|
4
|
+
消息格式:JSON + 换行符分隔
|
|
5
|
+
二进制数据使用 base64 编码
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import base64
|
|
10
|
+
from dataclasses import dataclass, asdict
|
|
11
|
+
from typing import Optional, List
|
|
12
|
+
from enum import Enum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MessageType(str, Enum):
|
|
16
|
+
"""消息类型"""
|
|
17
|
+
INPUT = "input" # 客户端 -> 服务端:用户输入
|
|
18
|
+
OUTPUT = "output" # 服务端 -> 客户端:Claude 输出
|
|
19
|
+
HISTORY = "history" # 历史输出(重连时)
|
|
20
|
+
ERROR = "error" # 错误消息
|
|
21
|
+
RESIZE = "resize" # 终端大小变化
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Message:
|
|
26
|
+
"""基础消息"""
|
|
27
|
+
type: str
|
|
28
|
+
|
|
29
|
+
def to_json(self) -> str:
|
|
30
|
+
return json.dumps(asdict(self), ensure_ascii=False)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_json(cls, data: str) -> "Message":
|
|
34
|
+
obj = json.loads(data)
|
|
35
|
+
msg_type = obj.get("type")
|
|
36
|
+
|
|
37
|
+
if msg_type == MessageType.INPUT:
|
|
38
|
+
return InputMessage.from_dict(obj)
|
|
39
|
+
elif msg_type == MessageType.OUTPUT:
|
|
40
|
+
return OutputMessage.from_dict(obj)
|
|
41
|
+
elif msg_type == MessageType.HISTORY:
|
|
42
|
+
return HistoryMessage.from_dict(obj)
|
|
43
|
+
elif msg_type == MessageType.ERROR:
|
|
44
|
+
return ErrorMessage.from_dict(obj)
|
|
45
|
+
elif msg_type == MessageType.RESIZE:
|
|
46
|
+
return ResizeMessage.from_dict(obj)
|
|
47
|
+
else:
|
|
48
|
+
raise ValueError(f"Unknown message type: {msg_type}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class InputMessage(Message):
|
|
53
|
+
"""用户输入消息"""
|
|
54
|
+
data: str # base64 编码的输入
|
|
55
|
+
client_id: str
|
|
56
|
+
|
|
57
|
+
def __init__(self, data: bytes, client_id: str):
|
|
58
|
+
super().__init__(type=MessageType.INPUT)
|
|
59
|
+
self.data = base64.b64encode(data).decode('ascii')
|
|
60
|
+
self.client_id = client_id
|
|
61
|
+
|
|
62
|
+
def get_data(self) -> bytes:
|
|
63
|
+
return base64.b64decode(self.data)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, obj: dict) -> "InputMessage":
|
|
67
|
+
msg = object.__new__(cls)
|
|
68
|
+
msg.type = obj["type"]
|
|
69
|
+
msg.data = obj["data"]
|
|
70
|
+
msg.client_id = obj["client_id"]
|
|
71
|
+
return msg
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class OutputMessage(Message):
|
|
76
|
+
"""Claude 输出消息"""
|
|
77
|
+
data: str # base64 编码的输出
|
|
78
|
+
|
|
79
|
+
def __init__(self, data: bytes):
|
|
80
|
+
super().__init__(type=MessageType.OUTPUT)
|
|
81
|
+
self.data = base64.b64encode(data).decode('ascii')
|
|
82
|
+
|
|
83
|
+
def get_data(self) -> bytes:
|
|
84
|
+
return base64.b64decode(self.data)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_dict(cls, obj: dict) -> "OutputMessage":
|
|
88
|
+
msg = object.__new__(cls)
|
|
89
|
+
msg.type = obj["type"]
|
|
90
|
+
msg.data = obj["data"]
|
|
91
|
+
return msg
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class HistoryMessage(Message):
|
|
96
|
+
"""历史输出消息(重连时发送)"""
|
|
97
|
+
data: str # base64 编码的历史输出
|
|
98
|
+
|
|
99
|
+
def __init__(self, data: bytes):
|
|
100
|
+
super().__init__(type=MessageType.HISTORY)
|
|
101
|
+
self.data = base64.b64encode(data).decode('ascii')
|
|
102
|
+
|
|
103
|
+
def get_data(self) -> bytes:
|
|
104
|
+
return base64.b64decode(self.data)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_dict(cls, obj: dict) -> "HistoryMessage":
|
|
108
|
+
msg = object.__new__(cls)
|
|
109
|
+
msg.type = obj["type"]
|
|
110
|
+
msg.data = obj["data"]
|
|
111
|
+
return msg
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class ErrorMessage(Message):
|
|
116
|
+
"""错误消息"""
|
|
117
|
+
message: str
|
|
118
|
+
code: Optional[str] = None
|
|
119
|
+
|
|
120
|
+
def __init__(self, message: str, code: Optional[str] = None):
|
|
121
|
+
super().__init__(type=MessageType.ERROR)
|
|
122
|
+
self.message = message
|
|
123
|
+
self.code = code
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_dict(cls, obj: dict) -> "ErrorMessage":
|
|
127
|
+
msg = object.__new__(cls)
|
|
128
|
+
msg.type = obj["type"]
|
|
129
|
+
msg.message = obj["message"]
|
|
130
|
+
msg.code = obj.get("code")
|
|
131
|
+
return msg
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class ResizeMessage(Message):
|
|
136
|
+
"""终端大小变化消息"""
|
|
137
|
+
rows: int
|
|
138
|
+
cols: int
|
|
139
|
+
client_id: str
|
|
140
|
+
|
|
141
|
+
def __init__(self, rows: int, cols: int, client_id: str):
|
|
142
|
+
super().__init__(type=MessageType.RESIZE)
|
|
143
|
+
self.rows = rows
|
|
144
|
+
self.cols = cols
|
|
145
|
+
self.client_id = client_id
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_dict(cls, obj: dict) -> "ResizeMessage":
|
|
149
|
+
msg = object.__new__(cls)
|
|
150
|
+
msg.type = obj["type"]
|
|
151
|
+
msg.rows = obj["rows"]
|
|
152
|
+
msg.cols = obj["cols"]
|
|
153
|
+
msg.client_id = obj["client_id"]
|
|
154
|
+
return msg
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def encode_message(msg: Message) -> bytes:
|
|
158
|
+
"""编码消息为字节流(JSON + 换行符)"""
|
|
159
|
+
return (msg.to_json() + "\n").encode('utf-8')
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def decode_message(data: bytes) -> Message:
|
|
163
|
+
"""解码消息"""
|
|
164
|
+
return Message.from_json(data.decode('utf-8').strip())
|