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.
@@ -0,0 +1,1114 @@
1
+ """
2
+ 飞书卡片构建器(Schema 2.0 格式)
3
+
4
+ 主要入口:
5
+ - build_stream_card(blocks, ...):从共享内存 blocks 流构建飞书卡片(供 SharedMemoryPoller 调用)
6
+
7
+ 辅助卡片:
8
+ - build_menu_card(内嵌会话列表 + 快捷操作,/menu 和 /list 共用)
9
+ - build_status_card / build_help_card / build_dir_card / build_session_closed_card
10
+ """
11
+
12
+ import logging
13
+ import re as _re
14
+ from typing import Dict, Any, List, Optional
15
+
16
+ _cb_logger = logging.getLogger('CardBuilder')
17
+
18
+ # ANSI SGR 前景色码 → 飞书颜色
19
+ # 飞书支持: blue, wathet, turquoise, green, yellow, orange, red, carmine, violet, purple, indigo, grey
20
+ _SGR_FG_TO_LARK = {
21
+ 30: 'grey', # black
22
+ 31: 'red', # red
23
+ 32: 'green', # green
24
+ 33: 'yellow', # yellow
25
+ 34: 'blue', # blue
26
+ 35: 'purple', # magenta → purple
27
+ 36: 'turquoise', # cyan → turquoise
28
+ 90: 'grey', # bright black
29
+ 91: 'red', # bright red
30
+ 92: 'green', # bright green
31
+ 93: 'yellow', # bright yellow
32
+ 94: 'wathet', # bright blue → wathet (浅蓝)
33
+ 95: 'violet', # bright magenta → violet
34
+ 96: 'turquoise', # bright cyan → turquoise
35
+ 37: 'grey', # white → grey(飞书无白色)
36
+ 97: 'grey', # bright white → grey
37
+ }
38
+ _ANSI_RE = _re.compile(r'\x1b\[([\d;]*)m')
39
+
40
+ # 飞书 12 色的近似 RGB 值
41
+ _LARK_COLORS_RGB = {
42
+ 'blue': (51, 112, 255),
43
+ 'wathet': (120, 163, 245),
44
+ 'turquoise': (45, 183, 181),
45
+ 'green': (52, 181, 74),
46
+ 'yellow': (250, 200, 0),
47
+ 'orange': (255, 125, 0),
48
+ 'red': (245, 74, 69),
49
+ 'carmine': (204, 41, 71),
50
+ 'violet': (155, 89, 182),
51
+ 'purple': (124, 58, 237),
52
+ 'indigo': (79, 70, 229),
53
+ 'grey': (143, 149, 158),
54
+ }
55
+
56
+
57
+ def _rgb_to_lark(r, g, b) -> str:
58
+ """RGB → 最近的飞书颜色(欧几里得距离)"""
59
+ best, best_d = 'grey', float('inf')
60
+ for name, (cr, cg, cb) in _LARK_COLORS_RGB.items():
61
+ d = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2
62
+ if d < best_d:
63
+ best, best_d = name, d
64
+ return best
65
+
66
+
67
+ def _256_to_lark(n: int) -> str:
68
+ """256 色索引 → 飞书颜色"""
69
+ if n < 8:
70
+ return _SGR_FG_TO_LARK.get(n + 30, 'grey')
71
+ if n < 16:
72
+ return _SGR_FG_TO_LARK.get(n - 8 + 90, 'grey')
73
+ if n < 232: # 6x6x6 色立方
74
+ n -= 16
75
+ r = (n // 36) * 51
76
+ g = ((n % 36) // 6) * 51
77
+ b = (n % 6) * 51
78
+ return _rgb_to_lark(r, g, b)
79
+ # 232-255: 灰阶
80
+ return 'grey'
81
+
82
+
83
+ def _escape_md(text: str) -> str:
84
+ """转义飞书 markdown 特殊字符,并保留行首缩进
85
+
86
+ 飞书 markdown 会压缩普通空格,将行首空格替换为不间断空格 (\\u00a0) 保留缩进。
87
+ """
88
+ if not text:
89
+ return ""
90
+ text = text.replace('\\', '\\\\')
91
+ for ch in ('*', '_', '~', '`'):
92
+ text = text.replace(ch, '\\' + ch)
93
+ # 行首空格 → 不间断空格,防止飞书 markdown 压缩缩进
94
+ lines = text.split('\n')
95
+ for i, line in enumerate(lines):
96
+ stripped = line.lstrip(' ')
97
+ indent = len(line) - len(stripped)
98
+ if indent > 0:
99
+ lines[i] = '\u00a0' * indent + stripped
100
+ return '\n'.join(lines)
101
+
102
+
103
+ def _ansi_to_lark_md(ansi_text: str) -> str:
104
+ """将 ANSI 转义序列转为飞书 <font color> markdown"""
105
+ if not ansi_text:
106
+ return ""
107
+ result = []
108
+ current_color = None
109
+ pos = 0
110
+ for match in _ANSI_RE.finditer(ansi_text):
111
+ # 匹配前的文本
112
+ text = ansi_text[pos:match.start()]
113
+ if text:
114
+ escaped = _escape_md(text)
115
+ if current_color:
116
+ result.append(f'<font color="{current_color}">{escaped}</font>')
117
+ else:
118
+ result.append(escaped)
119
+ # 解析 SGR 码(顺序消费,支持真彩色和 256 色)
120
+ codes = [int(c) for c in match.group(1).split(';') if c] if match.group(1) else [0]
121
+ i = 0
122
+ while i < len(codes):
123
+ c = codes[i]
124
+ if c == 0:
125
+ current_color = None
126
+ i += 1
127
+ elif c == 38 and i + 1 < len(codes):
128
+ if codes[i + 1] == 2 and i + 4 < len(codes): # 38;2;R;G;B 真彩色
129
+ current_color = _rgb_to_lark(codes[i + 2], codes[i + 3], codes[i + 4])
130
+ i += 5
131
+ elif codes[i + 1] == 5 and i + 2 < len(codes): # 38;5;N 256 色
132
+ current_color = _256_to_lark(codes[i + 2])
133
+ i += 3
134
+ else:
135
+ i += 1
136
+ elif c == 48 and i + 1 < len(codes): # 背景色,跳过
137
+ if codes[i + 1] == 2:
138
+ i += 5
139
+ elif codes[i + 1] == 5:
140
+ i += 3
141
+ else:
142
+ i += 1
143
+ elif c in _SGR_FG_TO_LARK:
144
+ current_color = _SGR_FG_TO_LARK[c]
145
+ i += 1
146
+ else:
147
+ i += 1
148
+ pos = match.end()
149
+ # 尾部文本
150
+ tail = ansi_text[pos:]
151
+ if tail:
152
+ escaped = _escape_md(tail)
153
+ if current_color:
154
+ result.append(f'<font color="{current_color}">{escaped}</font>')
155
+ else:
156
+ result.append(escaped)
157
+ merged = ''.join(result)
158
+ # 逐行后处理:行首缩进保留 + 分割线替换
159
+ lines = merged.split('\n')
160
+ for i, line in enumerate(lines):
161
+ # 去除 <font> 标签后检测是否为纯分割线字符行(终端分割线在卡片中无意义,直接移除)
162
+ plain = _re.sub(r'</?font[^>]*>', '', line).strip(' \u00a0')
163
+ if len(plain) >= 4 and all(c in '╌─━═' for c in plain):
164
+ lines[i] = ''
165
+ continue
166
+ # 行首空格 → 不间断空格,防止飞书 markdown 压缩缩进
167
+ stripped = line.lstrip(' ')
168
+ indent = len(line) - len(stripped)
169
+ if indent > 0:
170
+ lines[i] = '\u00a0' * indent + stripped
171
+ return '\n'.join(lines)
172
+
173
+
174
+ def _safe_truncate(text: str, limit: int) -> str:
175
+ """安全截断:不在代码块中间截断,超出时附加提示"""
176
+ if len(text) <= limit:
177
+ return text
178
+
179
+ truncated = text[:limit]
180
+ fence_count = truncated.count('```')
181
+ if fence_count % 2 == 1:
182
+ last_fence = truncated.rfind('```')
183
+ truncated = truncated[:last_fence].rstrip()
184
+ else:
185
+ last_newline = truncated.rfind('\n')
186
+ if last_newline > limit * 0.8:
187
+ truncated = truncated[:last_newline]
188
+
189
+ return truncated.rstrip() + '\n\n*...(内容过长,仅显示部分)*'
190
+
191
+
192
+ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: bool = False) -> List[Dict[str, Any]]:
193
+ """底部快捷菜单按钮行,用于流式卡片
194
+
195
+ - 连接状态(session_name 有值, disconnected=False):
196
+ 返回 [form, collapsible],form 包含:⚡菜单 + 🔌断开 + spacer + Enter↵,下方输入框;collapsible 包含快捷键
197
+ - 断开状态(disconnected=True):
198
+ 返回 [column_set: ⚡菜单 + 🔗重新连接],无输入框/Enter/快捷键
199
+ - 无 session_name:保持原逻辑(只有 ⚡菜单 + spacer + Enter↵)
200
+ """
201
+ if disconnected:
202
+ cols = [
203
+ {
204
+ "tag": "column",
205
+ "width": "auto",
206
+ "elements": [{
207
+ "tag": "button",
208
+ "text": {"tag": "plain_text", "content": "⚡ 菜单"},
209
+ "type": "default",
210
+ "behaviors": [{"type": "callback", "value": {"action": "menu_open"}}]
211
+ }]
212
+ },
213
+ {
214
+ "tag": "column",
215
+ "width": "auto",
216
+ "elements": [{
217
+ "tag": "button",
218
+ "text": {"tag": "plain_text", "content": "🔗 重新连接"},
219
+ "type": "primary",
220
+ "behaviors": [{"type": "callback", "value": {
221
+ "action": "stream_reconnect", "session": session_name or ""
222
+ }}]
223
+ }]
224
+ },
225
+ ]
226
+ return [{"tag": "column_set", "flex_mode": "none", "columns": cols}]
227
+
228
+ # 构建菜单行的 columns
229
+ if session_name:
230
+ menu_columns = [
231
+ {
232
+ "tag": "column",
233
+ "width": "auto",
234
+ "elements": [{
235
+ "tag": "button",
236
+ "text": {"tag": "plain_text", "content": "⚡ 菜单"},
237
+ "type": "default",
238
+ "behaviors": [{"type": "callback", "value": {"action": "menu_open"}}]
239
+ }]
240
+ },
241
+ {
242
+ "tag": "column",
243
+ "width": "auto",
244
+ "elements": [{
245
+ "tag": "button",
246
+ "text": {"tag": "plain_text", "content": "🔌 断开"},
247
+ "type": "danger",
248
+ "behaviors": [{"type": "callback", "value": {
249
+ "action": "stream_detach", "session": session_name
250
+ }}]
251
+ }]
252
+ },
253
+ {
254
+ "tag": "column",
255
+ "width": "weighted",
256
+ "weight": 1,
257
+ "elements": [{"tag": "markdown", "content": " "}]
258
+ },
259
+ {
260
+ "tag": "column",
261
+ "width": "auto",
262
+ "elements": [{
263
+ "tag": "button",
264
+ "text": {"tag": "plain_text", "content": "Enter ↵"},
265
+ "type": "primary",
266
+ "action_type": "form_submit",
267
+ }]
268
+ },
269
+ ]
270
+ else:
271
+ menu_columns = [
272
+ {
273
+ "tag": "column",
274
+ "width": "auto",
275
+ "elements": [{
276
+ "tag": "button",
277
+ "text": {"tag": "plain_text", "content": "⚡ 菜单"},
278
+ "type": "default",
279
+ "behaviors": [{"type": "callback", "value": {"action": "menu_open"}}]
280
+ }]
281
+ },
282
+ {
283
+ "tag": "column",
284
+ "width": "weighted",
285
+ "weight": 1,
286
+ "elements": [{"tag": "markdown", "content": " "}]
287
+ },
288
+ {
289
+ "tag": "column",
290
+ "width": "auto",
291
+ "elements": [{
292
+ "tag": "button",
293
+ "text": {"tag": "plain_text", "content": "Enter ↵"},
294
+ "type": "primary",
295
+ "action_type": "form_submit",
296
+ }]
297
+ },
298
+ ]
299
+
300
+ menu_enter_row = {"tag": "column_set", "flex_mode": "none", "columns": menu_columns}
301
+
302
+ input_box = {
303
+ "tag": "input",
304
+ "name": "command",
305
+ "placeholder": {"tag": "plain_text", "content": "输入消息..."},
306
+ "width": "fill",
307
+ }
308
+
309
+ shortcut_keys = [
310
+ ("↑", {"action": "send_key", "key": "up"}),
311
+ ("↓", {"action": "send_key", "key": "down"}),
312
+ ("Ctrl+O", {"action": "send_key", "key": "ctrl_o"}),
313
+ ("Shift+Tab", {"action": "send_key", "key": "shift_tab"}),
314
+ ("ESC", {"action": "send_key", "key": "esc"}),
315
+ ]
316
+
317
+ def _make_key_column(label, value):
318
+ return {
319
+ "tag": "column",
320
+ "width": "weighted",
321
+ "weight": 1,
322
+ "elements": [{
323
+ "tag": "button",
324
+ "text": {"tag": "plain_text", "content": label},
325
+ "type": "default",
326
+ "width": "fill",
327
+ "behaviors": [{"type": "callback", "value": value}],
328
+ }]
329
+ }
330
+
331
+ row1 = {
332
+ "tag": "column_set",
333
+ "flex_mode": "none",
334
+ "columns": [_make_key_column(l, v) for l, v in shortcut_keys[:3]],
335
+ }
336
+ row2 = {
337
+ "tag": "column_set",
338
+ "flex_mode": "none",
339
+ "columns": [_make_key_column(l, v) for l, v in shortcut_keys[3:]],
340
+ }
341
+
342
+ collapsible = {
343
+ "tag": "collapsible_panel",
344
+ "expanded": False,
345
+ "header": {
346
+ "title": {"tag": "plain_text", "content": "⌨️ 快捷键"},
347
+ },
348
+ "elements": [row1, row2],
349
+ }
350
+
351
+ form = {
352
+ "tag": "form",
353
+ "name": "claude_input",
354
+ "elements": [menu_enter_row, input_box],
355
+ }
356
+
357
+ return [form, collapsible]
358
+
359
+
360
+ def _build_menu_button_only() -> Dict[str, Any]:
361
+ """底部菜单按钮行(仅 ⚡菜单 按钮),用于辅助卡片"""
362
+ return {
363
+ "tag": "column_set",
364
+ "flex_mode": "none",
365
+ "columns": [{
366
+ "tag": "column",
367
+ "width": "auto",
368
+ "elements": [{
369
+ "tag": "button",
370
+ "text": {"tag": "plain_text", "content": "⚡ 菜单"},
371
+ "type": "default",
372
+ "behaviors": [{"type": "callback", "value": {"action": "menu_open"}}]
373
+ }]
374
+ }],
375
+ }
376
+
377
+
378
+ def _build_buttons_v2(options: List[Dict[str, str]]) -> List[Dict[str, Any]]:
379
+ """Schema 2.0 按钮组:每个按钮独占一行,顶部加一条 hr"""
380
+ total = len(options)
381
+ elements = [{"tag": "hr"}]
382
+ for i, opt in enumerate(options):
383
+ btn_type = "primary" if i == 0 else "default"
384
+ elements.append({
385
+ "tag": "column_set",
386
+ "flex_mode": "none",
387
+ "columns": [
388
+ {
389
+ "tag": "column",
390
+ "width": "weighted",
391
+ "weight": 1,
392
+ "horizontal_align": "left",
393
+ "elements": [
394
+ {
395
+ "tag": "button",
396
+ "text": {"tag": "plain_text", "content": f"{i+1}. {opt['label']}"},
397
+ "type": btn_type,
398
+ "behaviors": [
399
+ {
400
+ "type": "callback",
401
+ "value": {
402
+ "action": "select_option",
403
+ "value": opt["value"],
404
+ "total": str(total),
405
+ }
406
+ }
407
+ ]
408
+ }
409
+ ]
410
+ }
411
+ ]
412
+ })
413
+ return elements
414
+
415
+
416
+ # === 流式卡片构建(主入口)===
417
+
418
+ def _render_agent_panel(agent_panel: dict) -> List[Dict[str, Any]]:
419
+ """将 AgentPanelBlock 渲染为飞书 elements 列表
420
+
421
+ - summary → 纯文本(灰色背景已提供视觉区分)
422
+ - list → 代码块(agent 列表)
423
+ - detail → 代码块(agent 详情)
424
+ """
425
+ panel_type = agent_panel.get("panel_type", "")
426
+
427
+ if panel_type == "summary":
428
+ count = agent_panel.get("agent_count", 0)
429
+ return [{"tag": "markdown", "content": f"🤖 {count} 个后台 agent"}]
430
+
431
+ elif panel_type == "list":
432
+ count = agent_panel.get("agent_count", 0)
433
+ agents = agent_panel.get("agents", [])
434
+ lines = [f"🤖 后台任务 ({count})"]
435
+ for a in agents:
436
+ prefix = "❯" if a.get("is_selected") else " "
437
+ lines.append(f"{prefix} {a.get('name', '')} ({a.get('status', '')})")
438
+ return [{"tag": "markdown", "content": "```\n" + "\n".join(lines) + "\n```"}]
439
+
440
+ elif panel_type == "detail":
441
+ name = agent_panel.get("agent_name", "")
442
+ atype = agent_panel.get("agent_type", "")
443
+ stats = agent_panel.get("stats", "")
444
+ progress = agent_panel.get("progress", "")
445
+ prompt = agent_panel.get("prompt", "")
446
+ lines = [f"🤖 {atype} › {name}"]
447
+ if stats:
448
+ lines.append(stats)
449
+ if progress:
450
+ lines.append(f"Progress: {progress}")
451
+ if prompt:
452
+ lines.append(f"Prompt: {prompt}")
453
+ return [{"tag": "markdown", "content": "```\n" + "\n".join(lines) + "\n```"}]
454
+
455
+ return []
456
+
457
+
458
+ def _render_block_colored(block_dict: dict) -> Optional[str]:
459
+ """将单个 block dict 渲染为飞书 markdown(ANSI 着色)
460
+
461
+ 优先使用 ansi_* 字段解析为 <font color> 着色,
462
+ 无 ANSI 字段时回退到纯文本 + _escape_md。
463
+ """
464
+ typ = block_dict.get("_type", "")
465
+
466
+ if typ == "OutputBlock":
467
+ content = block_dict.get("content", "")
468
+ if not content:
469
+ return None
470
+ ansi_content = block_dict.get("ansi_content", "")
471
+ ansi_ind = block_dict.get("ansi_indicator", "")
472
+ indicator = block_dict.get("indicator", "●")
473
+ streaming = block_dict.get("is_streaming", False)
474
+ prefix = "⏳ " if streaming else ""
475
+ ind_md = _ansi_to_lark_md(ansi_ind) if ansi_ind else _escape_md(indicator)
476
+ content_md = _ansi_to_lark_md(ansi_content) if ansi_content else _escape_md(content)
477
+ return f"{prefix}{ind_md} {content_md}"
478
+
479
+ elif typ == "UserInput":
480
+ text = block_dict.get("text", "")
481
+ if not text:
482
+ return None
483
+ ansi_text = block_dict.get("ansi_text", "")
484
+ ansi_ind = block_dict.get("ansi_indicator", "")
485
+ ind_md = _ansi_to_lark_md(ansi_ind) if ansi_ind else "❯"
486
+ text_md = _ansi_to_lark_md(ansi_text) if ansi_text else _escape_md(text)
487
+ return f"{ind_md} {text_md}"
488
+
489
+ elif typ == "OptionBlock":
490
+ sub = block_dict.get("sub_type", "option")
491
+ if sub == "permission":
492
+ # 权限确认模式(向后兼容旧数据中 PermissionBlock 在 blocks 里的情况)
493
+ title = block_dict.get("title", "")
494
+ content = block_dict.get("content", "")
495
+ parts = []
496
+ if title:
497
+ parts.append(f"🔐 {_escape_md(title)}")
498
+ if content:
499
+ parts.append(_escape_md(content))
500
+ if not parts:
501
+ parts.append("🔐 权限确认")
502
+ return "\n".join(parts)
503
+ else:
504
+ question = block_dict.get("question", "")
505
+ tag = block_dict.get("tag", "")
506
+ display = question or tag or "请选择"
507
+ return f"🤔 {_escape_md(display)}"
508
+
509
+ elif typ == "PermissionBlock":
510
+ # 向后兼容旧数据
511
+ title = block_dict.get("title", "")
512
+ content = block_dict.get("content", "")
513
+ parts = []
514
+ if title:
515
+ parts.append(f"🔐 {_escape_md(title)}")
516
+ if content:
517
+ parts.append(_escape_md(content))
518
+ if not parts:
519
+ parts.append("🔐 权限确认")
520
+ return "\n".join(parts)
521
+
522
+ return None
523
+
524
+
525
+ def _determine_header(
526
+ blocks: List[dict],
527
+ status_line: Optional[dict],
528
+ bottom_bar: Optional[dict],
529
+ is_frozen: bool,
530
+ option_block: Optional[dict] = None,
531
+ disconnected: bool = False,
532
+ ) -> tuple:
533
+ """确定卡片标题和颜色模板,返回 (title, template)"""
534
+ if disconnected:
535
+ return "⚪ 已断开", "grey"
536
+
537
+ if is_frozen:
538
+ return "📋 会话记录", "grey"
539
+
540
+ has_streaming = any(b.get("is_streaming", False) for b in blocks)
541
+
542
+ if has_streaming or status_line:
543
+ if status_line:
544
+ action = status_line.get('action', '处理中...')
545
+ elapsed = status_line.get('elapsed', '')
546
+ tokens = status_line.get('tokens', '')
547
+ stats_parts = [p for p in [elapsed, tokens] if p]
548
+ stats_str = f" ({' · '.join(stats_parts)})" if stats_parts else ""
549
+ return f"⏳ {action}{stats_str}", "orange"
550
+ return "⏳ 处理中...", "orange"
551
+
552
+ # 优先从 option_block 状态型组件判定
553
+ if option_block:
554
+ if option_block.get("sub_type") == "permission":
555
+ return "🔐 等待权限确认", "red"
556
+ return "🤔 等待选择", "blue"
557
+
558
+ # 向后兼容:检查 blocks 中的旧 OptionBlock/PermissionBlock
559
+ last_type = blocks[-1].get("_type", "") if blocks else ""
560
+ if last_type == "PermissionBlock":
561
+ return "🔐 等待权限确认", "red"
562
+ if last_type == "OptionBlock":
563
+ sub = blocks[-1].get("sub_type", "option")
564
+ if sub == "permission":
565
+ return "🔐 等待权限确认", "red"
566
+ return "🤔 等待选择", "blue"
567
+
568
+ return "✅ Claude 就绪", "green"
569
+
570
+
571
+ def _extract_buttons(blocks: List[dict], option_block: Optional[dict] = None) -> List[Dict[str, str]]:
572
+ """从 option_block 状态型组件提取按钮选项,降级搜索 blocks 中的旧 OptionBlock/PermissionBlock"""
573
+ # 优先从 option_block 参数提取
574
+ if option_block:
575
+ return option_block.get("options", [])
576
+ # 向后兼容:搜索 blocks
577
+ for block in reversed(blocks):
578
+ typ = block.get("_type", "")
579
+ if typ in ("OptionBlock", "PermissionBlock"):
580
+ return block.get("options", [])
581
+ return []
582
+
583
+
584
+ def build_stream_card(
585
+ blocks: List[dict],
586
+ status_line: Optional[dict] = None,
587
+ bottom_bar: Optional[dict] = None,
588
+ is_frozen: bool = False,
589
+ agent_panel: Optional[dict] = None,
590
+ option_block: Optional[dict] = None,
591
+ session_name: Optional[str] = None,
592
+ disconnected: bool = False,
593
+ ) -> Dict[str, Any]:
594
+ """从共享内存 blocks 流构建飞书卡片
595
+
596
+ 四层结构:
597
+ 1. 内容区:累积型 blocks
598
+ 2. 状态区:status_line + bottom_bar + agent_panel + option_block 问题文本(断开时隐藏)
599
+ 3. 交互区:option_block 的选项按钮(断开时隐藏)
600
+ 4. 菜单按钮(断开时变为 [⚡菜单] [🔗重新连接])
601
+ """
602
+ title, template = _determine_header(
603
+ blocks, status_line, bottom_bar, is_frozen,
604
+ option_block=option_block, disconnected=disconnected
605
+ )
606
+
607
+ # === 第一层:内容区 ===
608
+ elements = []
609
+ has_content = False
610
+
611
+ for block_dict in blocks:
612
+ rendered = _render_block_colored(block_dict)
613
+ if rendered:
614
+ has_content = True
615
+ elements.append({"tag": "markdown", "content": rendered})
616
+
617
+
618
+ # === 第二层:状态区(仅非冻结且非断开时,column_set 灰色背景)===
619
+ if not is_frozen and not disconnected and (status_line or bottom_bar or agent_panel or option_block):
620
+ status_elements = []
621
+ if status_line:
622
+ ansi_raw = status_line.get('ansi_raw', '')
623
+ if ansi_raw:
624
+ status_elements.append({"tag": "markdown", "content": _ansi_to_lark_md(ansi_raw)})
625
+ else:
626
+ action = status_line.get('action', '')
627
+ elapsed = status_line.get('elapsed', '')
628
+ tokens = status_line.get('tokens', '')
629
+ stats_parts = [p for p in [elapsed, tokens] if p]
630
+ stats_str = f" ({' · '.join(stats_parts)})" if stats_parts else ""
631
+ status_elements.append({"tag": "markdown", "content": f"✱ {_escape_md(action)}{stats_str}"})
632
+ if bottom_bar:
633
+ # status_line 和 bottom_bar 之间加分割线
634
+ if status_line and status_elements:
635
+ status_elements.append({"tag": "hr"})
636
+ ansi_text = bottom_bar.get('ansi_text', '')
637
+ if ansi_text:
638
+ status_elements.append({"tag": "markdown", "content": _ansi_to_lark_md(ansi_text)})
639
+ else:
640
+ bar_text = bottom_bar.get('text', '')
641
+ if bar_text:
642
+ status_elements.append({"tag": "markdown", "content": _escape_md(bar_text)})
643
+ if agent_panel:
644
+ status_elements.extend(_render_agent_panel(agent_panel))
645
+ # option_block 显示在状态区(优先用 ansi_raw 渲染颜色)
646
+ if option_block:
647
+ # 前面有内容时加分割线
648
+ if status_elements:
649
+ status_elements.append({"tag": "hr"})
650
+ ob_ansi = option_block.get("ansi_raw", "")
651
+ if ob_ansi:
652
+ status_elements.append({"tag": "markdown", "content": _ansi_to_lark_md(ob_ansi)})
653
+ else:
654
+ sub = option_block.get("sub_type", "option")
655
+ if sub == "permission":
656
+ ob_title = option_block.get("title", "")
657
+ ob_content = option_block.get("content", "")
658
+ ob_parts = []
659
+ if ob_title:
660
+ ob_parts.append(f"🔐 {_escape_md(ob_title)}")
661
+ if ob_content:
662
+ ob_parts.append(_escape_md(ob_content))
663
+ if not ob_parts:
664
+ ob_parts.append("🔐 权限确认")
665
+ status_elements.append({"tag": "markdown", "content": "\n".join(ob_parts)})
666
+ else:
667
+ ob_question = option_block.get("question", "")
668
+ ob_tag = option_block.get("tag", "")
669
+ ob_display = ob_question or ob_tag or "请选择"
670
+ status_elements.append({"tag": "markdown", "content": f"🤔 {_escape_md(ob_display)}"})
671
+ if status_elements:
672
+ elements.append({
673
+ "tag": "column_set",
674
+ "flex_mode": "none",
675
+ "background_style": "grey",
676
+ "columns": [{
677
+ "tag": "column",
678
+ "width": "weighted",
679
+ "weight": 1,
680
+ "elements": status_elements,
681
+ }],
682
+ })
683
+
684
+ # === 第三层:交互按钮区(仅非冻结且非断开时)===
685
+ if not is_frozen and not disconnected:
686
+ buttons = _extract_buttons(blocks, option_block=option_block)
687
+ if buttons:
688
+ elements.extend(_build_buttons_v2(buttons))
689
+
690
+ # === 第四层:菜单按钮 ===
691
+ elements.append({"tag": "hr"})
692
+ elements.extend(_build_menu_button_row(session_name=session_name, disconnected=disconnected))
693
+
694
+ _cb_logger.debug(
695
+ f"build_stream_card: blocks={len(blocks)} frozen={is_frozen} "
696
+ f"title={title!r} elements={len(elements)}"
697
+ )
698
+
699
+ return {
700
+ "schema": "2.0",
701
+ "config": {"wide_screen_mode": True, "enable_forward": True},
702
+ "header": {
703
+ "title": {"tag": "plain_text", "content": title},
704
+ "template": template,
705
+ },
706
+ "body": {"elements": elements},
707
+ }
708
+
709
+
710
+ # === 辅助卡片(保留不变)===
711
+
712
+ def _build_session_list_elements(sessions: List[Dict], current_session: Optional[str], session_groups: Optional[Dict[str, str]]) -> List[Dict]:
713
+ """构建会话列表元素(供 build_menu_card 复用)"""
714
+ import os
715
+ elements = []
716
+ if sessions:
717
+ for s in sessions:
718
+ name = s["name"]
719
+ cwd = s.get("cwd", "")
720
+ start_time = s.get("start_time", "")
721
+ is_current = (name == current_session)
722
+
723
+ status_icon = "🟢" if is_current else "⚪"
724
+ current_label = "(当前)" if is_current else ""
725
+ if cwd:
726
+ short_name = cwd.rstrip("/").rsplit("/", 1)[-1] or name
727
+ else:
728
+ short_name = name
729
+ meta_parts = []
730
+ if start_time:
731
+ meta_parts.append(f"启动:{start_time}")
732
+ if cwd:
733
+ home = os.path.expanduser("~")
734
+ display_cwd = cwd.replace(home, "~")
735
+ if len(display_cwd) > 40:
736
+ parts = display_cwd.rstrip("/").rsplit("/", 2)
737
+ display_cwd = "…/" + "/".join(parts[-2:]) if len(parts) > 2 else display_cwd[-40:]
738
+ meta_parts.append(f"`{display_cwd}`")
739
+ meta_str = " ".join(meta_parts) if meta_parts else ""
740
+
741
+ header_text = f"{status_icon} **{short_name}**{current_label}"
742
+ if meta_str:
743
+ header_text += f"\n{meta_str}"
744
+
745
+ if is_current:
746
+ btn_label = "断开连接"
747
+ btn_type = "danger"
748
+ btn_action = "list_detach"
749
+ else:
750
+ btn_label = "进入会话"
751
+ btn_type = "primary"
752
+ btn_action = "list_attach"
753
+ columns = [
754
+ {
755
+ "tag": "column",
756
+ "width": "weighted",
757
+ "weight": 5,
758
+ "elements": [{"tag": "markdown", "content": header_text}]
759
+ },
760
+ {
761
+ "tag": "column",
762
+ "width": "weighted",
763
+ "weight": 2,
764
+ "elements": [{
765
+ "tag": "button",
766
+ "text": {"tag": "plain_text", "content": btn_label},
767
+ "type": btn_type,
768
+ "behaviors": [{"type": "callback", "value": {
769
+ "action": btn_action, "session": name
770
+ }}]
771
+ }]
772
+ },
773
+ {
774
+ "tag": "column",
775
+ "width": "weighted",
776
+ "weight": 2,
777
+ "elements": [{
778
+ "tag": "button",
779
+ "text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and name in session_groups) else "创建群聊"},
780
+ "type": "default",
781
+ "behaviors": [{"type": "open_url", "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}"}]
782
+ if (session_groups and name in session_groups) else
783
+ [{"type": "callback", "value": {"action": "list_new_group", "session": name}}]
784
+ }]
785
+ },
786
+ ]
787
+ if session_groups and name in session_groups:
788
+ columns.append({
789
+ "tag": "column",
790
+ "width": "weighted",
791
+ "weight": 2,
792
+ "elements": [{
793
+ "tag": "button",
794
+ "text": {"tag": "plain_text", "content": "解散群聊"},
795
+ "type": "danger",
796
+ "behaviors": [{"type": "callback", "value": {
797
+ "action": "list_disband_group", "session": name
798
+ }}]
799
+ }]
800
+ })
801
+ elements.append({
802
+ "tag": "column_set",
803
+ "flex_mode": "none",
804
+ "columns": columns
805
+ })
806
+ elements.append({"tag": "hr"})
807
+
808
+ if elements and elements[-1].get("tag") == "hr":
809
+ elements.pop()
810
+ else:
811
+ elements.append({
812
+ "tag": "markdown",
813
+ "content": "暂无可用会话\n\n请先在终端启动:`python remote_claude.py start <名称>`"
814
+ })
815
+ return elements
816
+
817
+
818
+ def build_status_card(connected: bool, session_name: Optional[str] = None) -> Dict[str, Any]:
819
+ """构建状态卡片"""
820
+ if connected and session_name:
821
+ title = "🟢 已连接"
822
+ template = "green"
823
+ content = f"当前会话:**{session_name}**"
824
+ else:
825
+ title = "⚪ 未连接"
826
+ template = "grey"
827
+ content = "使用 `/attach <会话名>` 连接到 Claude 会话"
828
+
829
+ return {
830
+ "schema": "2.0",
831
+ "config": {"wide_screen_mode": True},
832
+ "header": {
833
+ "title": {"tag": "plain_text", "content": title},
834
+ "template": template,
835
+ },
836
+ "body": {"elements": [
837
+ {"tag": "markdown", "content": content},
838
+ {"tag": "hr"},
839
+ _build_menu_button_only(),
840
+ ]}
841
+ }
842
+
843
+
844
+ def _dir_session_name(path: str) -> str:
845
+ """从目录路径生成合法会话名(取最后一段,转小写,非字母数字替换为-)"""
846
+ import os
847
+ basename = os.path.basename(path.rstrip("/")) or "session"
848
+ name = _re.sub(r"[^a-z0-9]+", "-", basename.lower()).strip("-")
849
+ return name or "session"
850
+
851
+
852
+ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool = False, session_groups: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
853
+ """构建目录浏览卡片
854
+
855
+ 顶层目录(depth==0)带两个操作按钮:
856
+ - 「📂 进入」:导航进入该子目录(继续浏览)
857
+ - 「🚀 在此启动」:在该目录创建新 Claude 会话
858
+
859
+ entries 格式: [{"name": str, "full_path": str, "is_dir": bool, "depth": int}]
860
+ sessions 格式: [{"name": str, "cwd": str}](仅用于信息展示,不影响按钮可用性)
861
+ """
862
+ import os
863
+ title = f"🌲 {target}" if tree else f"📂 {target}"
864
+ elements = []
865
+
866
+ target_str = str(target).rstrip("/") or "/"
867
+ parent_path = os.path.dirname(target_str)
868
+ if parent_path and parent_path != target_str:
869
+ elements.append({
870
+ "tag": "column_set",
871
+ "flex_mode": "none",
872
+ "columns": [{
873
+ "tag": "column",
874
+ "width": "auto",
875
+ "elements": [{
876
+ "tag": "button",
877
+ "text": {"tag": "plain_text", "content": "⬆️ 返回上级"},
878
+ "type": "default",
879
+ "behaviors": [{"type": "callback", "value": {
880
+ "action": "dir_browse", "path": parent_path
881
+ }}]
882
+ }]
883
+ }]
884
+ })
885
+ elements.append({"tag": "hr"})
886
+
887
+ cap = 20
888
+ total = len(entries)
889
+ shown = entries[:cap]
890
+
891
+ for entry in shown:
892
+ name = entry["name"]
893
+ is_dir = entry["is_dir"]
894
+ depth = entry.get("depth", 0)
895
+ full_path = entry.get("full_path", "")
896
+ indent = " " * depth
897
+ icon = "📁" if is_dir else "📄"
898
+
899
+ if is_dir and depth == 0:
900
+ auto_session = _dir_session_name(full_path)
901
+ elements.append({
902
+ "tag": "column_set",
903
+ "flex_mode": "none",
904
+ "columns": [
905
+ {
906
+ "tag": "column",
907
+ "width": "weighted",
908
+ "weight": 3,
909
+ "elements": [{"tag": "markdown", "content": f"{icon} **{name}**"}]
910
+ },
911
+ {
912
+ "tag": "column",
913
+ "width": "weighted",
914
+ "weight": 2,
915
+ "elements": [{
916
+ "tag": "button",
917
+ "text": {"tag": "plain_text", "content": "📂 进入"},
918
+ "type": "default",
919
+ "behaviors": [{"type": "callback", "value": {
920
+ "action": "dir_browse", "path": full_path
921
+ }}]
922
+ }]
923
+ },
924
+ {
925
+ "tag": "column",
926
+ "width": "weighted",
927
+ "weight": 2,
928
+ "elements": [{
929
+ "tag": "button",
930
+ "text": {"tag": "plain_text", "content": "🚀 在此启动"},
931
+ "type": "primary",
932
+ "behaviors": [{"type": "callback", "value": {
933
+ "action": "dir_start",
934
+ "path": full_path,
935
+ "session_name": auto_session
936
+ }}]
937
+ }]
938
+ },
939
+ {
940
+ "tag": "column",
941
+ "width": "weighted",
942
+ "weight": 2,
943
+ "elements": [{
944
+ "tag": "button",
945
+ "text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and auto_session in session_groups) else "创建群聊"},
946
+ "type": "default",
947
+ "behaviors": [{"type": "open_url", "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
948
+ if (session_groups and auto_session in session_groups) else
949
+ [{"type": "callback", "value": {
950
+ "action": "dir_new_group",
951
+ "path": full_path,
952
+ "session_name": auto_session
953
+ }}]
954
+ }]
955
+ }
956
+ ]
957
+ })
958
+ else:
959
+ elements.append({"tag": "markdown", "content": f"{indent}{icon} {name}"})
960
+
961
+ if total > cap:
962
+ elements.append({"tag": "markdown", "content": f"*...(共 {total} 项,仅显示前 {cap} 项)*"})
963
+
964
+ elements.append({"tag": "hr"})
965
+ elements.append(_build_menu_button_only())
966
+
967
+ return {
968
+ "schema": "2.0",
969
+ "config": {"wide_screen_mode": True},
970
+ "header": {"title": {"tag": "plain_text", "content": title}, "template": "blue"},
971
+ "body": {"elements": elements}
972
+ }
973
+
974
+
975
+ def build_help_card() -> Dict[str, Any]:
976
+ """构建帮助卡片"""
977
+ help_content = """**🚀 快速开始**
978
+ • `/menu` - 弹出快捷操作面板(推荐入口)
979
+
980
+ **会话管理**
981
+ • `/start <会话名> [工作路径]` - 启动新会话并自动连接
982
+ • `/attach <会话名>` - 连接到已有会话
983
+ • `/detach` - 断开当前会话
984
+ • `/list` - 列出所有可用会话(带一键 Attach 按钮)
985
+ • `/kill <会话名>` - 终止会话
986
+ • `/status` - 显示当前连接状态
987
+
988
+ **目录浏览**
989
+ • `/ls [路径]` - 查看文件列表
990
+ • `/tree [路径]` - 查看目录树(2 层)
991
+
992
+ **群聊协作**
993
+ • `/new-group <会话名>` - 创建专属群聊,多人共用同一 Claude
994
+
995
+ **其他**
996
+ • `/help` - 显示此帮助
997
+ • `/menu` - 快捷操作面板"""
998
+
999
+ return {
1000
+ "schema": "2.0",
1001
+ "config": {"wide_screen_mode": True},
1002
+ "header": {
1003
+ "title": {"tag": "plain_text", "content": "📖 Remote Claude 帮助"},
1004
+ "template": "blue",
1005
+ },
1006
+ "body": {"elements": [
1007
+ {"tag": "markdown", "content": help_content},
1008
+ {"tag": "hr"},
1009
+ _build_menu_button_only(),
1010
+ ]}
1011
+ }
1012
+
1013
+
1014
+ def build_session_closed_card(session_name: str) -> Dict[str, Any]:
1015
+ """构建会话关闭通知卡片(服务端关闭时推送给用户)"""
1016
+ return {
1017
+ "schema": "2.0",
1018
+ "config": {"wide_screen_mode": True},
1019
+ "header": {
1020
+ "title": {"tag": "plain_text", "content": "🔴 会话已关闭"},
1021
+ "template": "red",
1022
+ },
1023
+ "body": {"elements": [
1024
+ {"tag": "markdown", "content": f"会话 **{session_name}** 已关闭,连接已自动断开。\n\n如需继续,请重新启动会话或连接到其他会话。"},
1025
+ {"tag": "hr"},
1026
+ {
1027
+ "tag": "column_set",
1028
+ "flex_mode": "none",
1029
+ "columns": [
1030
+ {
1031
+ "tag": "column",
1032
+ "width": "auto",
1033
+ "elements": [{
1034
+ "tag": "button",
1035
+ "text": {"tag": "plain_text", "content": "📋 查看会话"},
1036
+ "type": "primary",
1037
+ "behaviors": [{"type": "callback", "value": {"action": "menu_list"}}]
1038
+ }]
1039
+ },
1040
+ {
1041
+ "tag": "column",
1042
+ "width": "auto",
1043
+ "elements": [{
1044
+ "tag": "button",
1045
+ "text": {"tag": "plain_text", "content": "⚡ 菜单"},
1046
+ "type": "default",
1047
+ "behaviors": [{"type": "callback", "value": {"action": "menu_open"}}]
1048
+ }]
1049
+ }
1050
+ ]
1051
+ }
1052
+ ]}
1053
+ }
1054
+
1055
+
1056
+ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1057
+ session_groups: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
1058
+ """构建快捷操作菜单卡片(/menu 和 /list 共用):内嵌会话列表 + 快捷操作"""
1059
+ elements = []
1060
+
1061
+ elements.append({"tag": "markdown", "content": "**会话管理**"})
1062
+ elements.extend(_build_session_list_elements(sessions, current_session, session_groups))
1063
+
1064
+ elements.append({"tag": "hr"})
1065
+ elements.append({"tag": "markdown", "content": "**快捷操作**"})
1066
+ elements.append({
1067
+ "tag": "column_set",
1068
+ "flex_mode": "none",
1069
+ "columns": [
1070
+ {
1071
+ "tag": "column",
1072
+ "width": "weighted",
1073
+ "weight": 1,
1074
+ "elements": [{
1075
+ "tag": "button",
1076
+ "text": {"tag": "plain_text", "content": "📖 帮助"},
1077
+ "type": "default",
1078
+ "behaviors": [{"type": "callback", "value": {"action": "menu_help"}}]
1079
+ }]
1080
+ },
1081
+ {
1082
+ "tag": "column",
1083
+ "width": "weighted",
1084
+ "weight": 1,
1085
+ "elements": [{
1086
+ "tag": "button",
1087
+ "text": {"tag": "plain_text", "content": "📂 文件列表"},
1088
+ "type": "default",
1089
+ "behaviors": [{"type": "callback", "value": {"action": "menu_ls"}}]
1090
+ }]
1091
+ },
1092
+ {
1093
+ "tag": "column",
1094
+ "width": "weighted",
1095
+ "weight": 1,
1096
+ "elements": [{
1097
+ "tag": "button",
1098
+ "text": {"tag": "plain_text", "content": "🌲 目录树"},
1099
+ "type": "default",
1100
+ "behaviors": [{"type": "callback", "value": {"action": "menu_tree"}}]
1101
+ }]
1102
+ },
1103
+ ]
1104
+ })
1105
+
1106
+ return {
1107
+ "schema": "2.0",
1108
+ "config": {"wide_screen_mode": True},
1109
+ "header": {
1110
+ "title": {"tag": "plain_text", "content": "⚡ 快捷操作"},
1111
+ "template": "turquoise",
1112
+ },
1113
+ "body": {"elements": elements}
1114
+ }