remote-claude 0.1.6 → 0.2.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/lark_client/card_builder.py +128 -97
- package/lark_client/card_service.py +21 -0
- package/lark_client/lark_handler.py +94 -31
- package/lark_client/main.py +39 -7
- package/lark_client/shared_memory_poller.py +49 -1
- package/package.json +1 -1
- package/server/server.py +9 -9
|
@@ -261,6 +261,7 @@ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: boo
|
|
|
261
261
|
"width": "auto",
|
|
262
262
|
"elements": [{
|
|
263
263
|
"tag": "button",
|
|
264
|
+
"name": "enter_submit",
|
|
264
265
|
"text": {"tag": "plain_text", "content": "Enter ↵"},
|
|
265
266
|
"type": "primary",
|
|
266
267
|
"action_type": "form_submit",
|
|
@@ -290,6 +291,7 @@ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: boo
|
|
|
290
291
|
"width": "auto",
|
|
291
292
|
"elements": [{
|
|
292
293
|
"tag": "button",
|
|
294
|
+
"name": "enter_submit",
|
|
293
295
|
"text": {"tag": "plain_text", "content": "Enter ↵"},
|
|
294
296
|
"type": "primary",
|
|
295
297
|
"action_type": "form_submit",
|
|
@@ -312,6 +314,7 @@ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: boo
|
|
|
312
314
|
("Ctrl+O", {"action": "send_key", "key": "ctrl_o"}),
|
|
313
315
|
("Shift+Tab", {"action": "send_key", "key": "shift_tab"}),
|
|
314
316
|
("ESC", {"action": "send_key", "key": "esc"}),
|
|
317
|
+
("(↹)×3", {"action": "send_key", "key": "shift_tab", "times": 3}),
|
|
315
318
|
]
|
|
316
319
|
|
|
317
320
|
def _make_key_column(label, value):
|
|
@@ -773,58 +776,57 @@ def _build_session_list_elements(sessions: List[Dict], current_session: Optional
|
|
|
773
776
|
btn_label = "进入会话"
|
|
774
777
|
btn_type = "primary"
|
|
775
778
|
btn_action = "list_attach"
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
"weight": 5,
|
|
781
|
-
"elements": [{"tag": "markdown", "content": header_text}]
|
|
782
|
-
},
|
|
779
|
+
has_group = bool(session_groups and name in session_groups)
|
|
780
|
+
|
|
781
|
+
# 右列按钮(纵向堆叠)
|
|
782
|
+
right_buttons = [
|
|
783
783
|
{
|
|
784
|
-
"tag": "
|
|
785
|
-
"
|
|
786
|
-
"
|
|
787
|
-
"
|
|
788
|
-
"
|
|
789
|
-
|
|
790
|
-
"type": btn_type,
|
|
791
|
-
"behaviors": [{"type": "callback", "value": {
|
|
792
|
-
"action": btn_action, "session": name
|
|
793
|
-
}}]
|
|
794
|
-
}]
|
|
784
|
+
"tag": "button",
|
|
785
|
+
"text": {"tag": "plain_text", "content": btn_label},
|
|
786
|
+
"type": btn_type,
|
|
787
|
+
"behaviors": [{"type": "callback", "value": {
|
|
788
|
+
"action": btn_action, "session": name
|
|
789
|
+
}}]
|
|
795
790
|
},
|
|
796
791
|
{
|
|
797
|
-
"tag": "
|
|
798
|
-
"
|
|
799
|
-
"
|
|
800
|
-
"
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
}]
|
|
792
|
+
"tag": "button",
|
|
793
|
+
"text": {"tag": "plain_text", "content": "进入群聊" if has_group else "创建群聊"},
|
|
794
|
+
"type": "default",
|
|
795
|
+
"behaviors": [{"type": "open_url",
|
|
796
|
+
"default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}",
|
|
797
|
+
"android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}",
|
|
798
|
+
"ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}",
|
|
799
|
+
"pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}"}]
|
|
800
|
+
if has_group else
|
|
801
|
+
[{"type": "callback", "value": {"action": "list_new_group", "session": name}}]
|
|
808
802
|
},
|
|
809
803
|
]
|
|
810
|
-
if
|
|
811
|
-
|
|
812
|
-
"tag": "
|
|
813
|
-
"
|
|
814
|
-
"
|
|
815
|
-
"
|
|
816
|
-
"
|
|
817
|
-
|
|
818
|
-
"type": "danger",
|
|
819
|
-
"behaviors": [{"type": "callback", "value": {
|
|
820
|
-
"action": "list_disband_group", "session": name
|
|
821
|
-
}}]
|
|
822
|
-
}]
|
|
804
|
+
if has_group:
|
|
805
|
+
right_buttons.append({
|
|
806
|
+
"tag": "button",
|
|
807
|
+
"text": {"tag": "plain_text", "content": "解散群聊"},
|
|
808
|
+
"type": "danger",
|
|
809
|
+
"behaviors": [{"type": "callback", "value": {
|
|
810
|
+
"action": "list_disband_group", "session": name
|
|
811
|
+
}}]
|
|
823
812
|
})
|
|
824
813
|
elements.append({
|
|
825
814
|
"tag": "column_set",
|
|
826
815
|
"flex_mode": "none",
|
|
827
|
-
"columns":
|
|
816
|
+
"columns": [
|
|
817
|
+
{
|
|
818
|
+
"tag": "column",
|
|
819
|
+
"width": "weighted",
|
|
820
|
+
"weight": 3,
|
|
821
|
+
"elements": [{"tag": "markdown", "content": header_text}]
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
"tag": "column",
|
|
825
|
+
"width": "weighted",
|
|
826
|
+
"weight": 2,
|
|
827
|
+
"elements": right_buttons
|
|
828
|
+
},
|
|
829
|
+
]
|
|
828
830
|
})
|
|
829
831
|
elements.append({"tag": "hr"})
|
|
830
832
|
|
|
@@ -872,7 +874,7 @@ def _dir_session_name(path: str) -> str:
|
|
|
872
874
|
return name or "session"
|
|
873
875
|
|
|
874
876
|
|
|
875
|
-
def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool = False, session_groups: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
|
877
|
+
def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool = False, session_groups: Optional[Dict[str, str]] = None, page: int = 0) -> Dict[str, Any]:
|
|
876
878
|
"""构建目录浏览卡片
|
|
877
879
|
|
|
878
880
|
顶层目录(depth==0)带两个操作按钮:
|
|
@@ -907,9 +909,17 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
|
|
|
907
909
|
})
|
|
908
910
|
elements.append({"tag": "hr"})
|
|
909
911
|
|
|
910
|
-
|
|
912
|
+
PER_PAGE = 12
|
|
911
913
|
total = len(entries)
|
|
912
|
-
|
|
914
|
+
if tree:
|
|
915
|
+
# tree 模式不分页,直接展示全部(已有 max_items 上限)
|
|
916
|
+
shown = entries
|
|
917
|
+
page = 0
|
|
918
|
+
total_pages = 1
|
|
919
|
+
else:
|
|
920
|
+
total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
|
|
921
|
+
page = max(0, min(page, total_pages - 1))
|
|
922
|
+
shown = entries[page * PER_PAGE : (page + 1) * PER_PAGE]
|
|
913
923
|
|
|
914
924
|
for entry in shown:
|
|
915
925
|
name = entry["name"]
|
|
@@ -921,6 +931,22 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
|
|
|
921
931
|
|
|
922
932
|
if is_dir and depth == 0:
|
|
923
933
|
auto_session = _dir_session_name(full_path)
|
|
934
|
+
group_btn = {
|
|
935
|
+
"tag": "button",
|
|
936
|
+
"text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and auto_session in session_groups) else "创建群聊"},
|
|
937
|
+
"type": "default",
|
|
938
|
+
"behaviors": [{"type": "open_url",
|
|
939
|
+
"default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
|
|
940
|
+
"android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
|
|
941
|
+
"ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
|
|
942
|
+
"pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
|
|
943
|
+
if (session_groups and auto_session in session_groups) else
|
|
944
|
+
[{"type": "callback", "value": {
|
|
945
|
+
"action": "dir_new_group",
|
|
946
|
+
"path": full_path,
|
|
947
|
+
"session_name": auto_session
|
|
948
|
+
}}]
|
|
949
|
+
}
|
|
924
950
|
elements.append({
|
|
925
951
|
"tag": "column_set",
|
|
926
952
|
"flex_mode": "none",
|
|
@@ -928,61 +954,77 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
|
|
|
928
954
|
{
|
|
929
955
|
"tag": "column",
|
|
930
956
|
"width": "weighted",
|
|
931
|
-
"weight":
|
|
932
|
-
"elements": [{"tag": "markdown", "content": f"{icon} **{name}**"}]
|
|
933
|
-
},
|
|
934
|
-
{
|
|
935
|
-
"tag": "column",
|
|
936
|
-
"width": "weighted",
|
|
937
|
-
"weight": 2,
|
|
957
|
+
"weight": 4,
|
|
938
958
|
"elements": [{
|
|
939
|
-
"tag": "
|
|
940
|
-
"
|
|
941
|
-
"
|
|
959
|
+
"tag": "interactive_container",
|
|
960
|
+
"width": "fill",
|
|
961
|
+
"height": "auto",
|
|
942
962
|
"behaviors": [{"type": "callback", "value": {
|
|
943
963
|
"action": "dir_browse", "path": full_path
|
|
944
|
-
}}]
|
|
964
|
+
}}],
|
|
965
|
+
"elements": [{"tag": "markdown", "content": f"📁 **{name}**"}]
|
|
945
966
|
}]
|
|
946
967
|
},
|
|
947
968
|
{
|
|
948
969
|
"tag": "column",
|
|
949
970
|
"width": "weighted",
|
|
950
971
|
"weight": 2,
|
|
951
|
-
"elements": [
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
"
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
"width": "weighted",
|
|
965
|
-
"weight": 2,
|
|
966
|
-
"elements": [{
|
|
967
|
-
"tag": "button",
|
|
968
|
-
"text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and auto_session in session_groups) else "创建群聊"},
|
|
969
|
-
"type": "default",
|
|
970
|
-
"behaviors": [{"type": "open_url", "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
|
|
971
|
-
if (session_groups and auto_session in session_groups) else
|
|
972
|
-
[{"type": "callback", "value": {
|
|
973
|
-
"action": "dir_new_group",
|
|
974
|
-
"path": full_path,
|
|
975
|
-
"session_name": auto_session
|
|
976
|
-
}}]
|
|
977
|
-
}]
|
|
972
|
+
"elements": [
|
|
973
|
+
{
|
|
974
|
+
"tag": "button",
|
|
975
|
+
"text": {"tag": "plain_text", "content": "Claude"},
|
|
976
|
+
"type": "primary",
|
|
977
|
+
"behaviors": [{"type": "callback", "value": {
|
|
978
|
+
"action": "dir_start",
|
|
979
|
+
"path": full_path,
|
|
980
|
+
"session_name": auto_session
|
|
981
|
+
}}]
|
|
982
|
+
},
|
|
983
|
+
group_btn
|
|
984
|
+
]
|
|
978
985
|
}
|
|
979
986
|
]
|
|
980
987
|
})
|
|
988
|
+
elements.append({"tag": "hr"})
|
|
981
989
|
else:
|
|
982
990
|
elements.append({"tag": "markdown", "content": f"{indent}{icon} {name}"})
|
|
983
991
|
|
|
984
|
-
if total >
|
|
985
|
-
|
|
992
|
+
if not tree and total > PER_PAGE:
|
|
993
|
+
page_cols = []
|
|
994
|
+
if page > 0:
|
|
995
|
+
page_cols.append({
|
|
996
|
+
"tag": "column",
|
|
997
|
+
"width": "auto",
|
|
998
|
+
"elements": [{
|
|
999
|
+
"tag": "button",
|
|
1000
|
+
"text": {"tag": "plain_text", "content": "⬅️ 上一页"},
|
|
1001
|
+
"type": "default",
|
|
1002
|
+
"behaviors": [{"type": "callback", "value": {
|
|
1003
|
+
"action": "dir_page", "path": target_str, "page": page - 1
|
|
1004
|
+
}}]
|
|
1005
|
+
}]
|
|
1006
|
+
})
|
|
1007
|
+
page_cols.append({
|
|
1008
|
+
"tag": "column",
|
|
1009
|
+
"width": "weighted",
|
|
1010
|
+
"weight": 2,
|
|
1011
|
+
"vertical_align": "center",
|
|
1012
|
+
"elements": [{"tag": "markdown", "content": f"第 {page + 1}/{total_pages} 页"}]
|
|
1013
|
+
})
|
|
1014
|
+
if page < total_pages - 1:
|
|
1015
|
+
page_cols.append({
|
|
1016
|
+
"tag": "column",
|
|
1017
|
+
"width": "auto",
|
|
1018
|
+
"elements": [{
|
|
1019
|
+
"tag": "button",
|
|
1020
|
+
"text": {"tag": "plain_text", "content": "下一页 ➡️"},
|
|
1021
|
+
"type": "default",
|
|
1022
|
+
"behaviors": [{"type": "callback", "value": {
|
|
1023
|
+
"action": "dir_page", "path": target_str, "page": page + 1
|
|
1024
|
+
}}]
|
|
1025
|
+
}]
|
|
1026
|
+
})
|
|
1027
|
+
elements.append({"tag": "column_set", "flex_mode": "none", "columns": page_cols})
|
|
986
1028
|
|
|
987
1029
|
elements.append({"tag": "hr"})
|
|
988
1030
|
elements.append(_build_menu_button_only())
|
|
@@ -1090,17 +1132,6 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
|
|
|
1090
1132
|
"tag": "column_set",
|
|
1091
1133
|
"flex_mode": "none",
|
|
1092
1134
|
"columns": [
|
|
1093
|
-
{
|
|
1094
|
-
"tag": "column",
|
|
1095
|
-
"width": "weighted",
|
|
1096
|
-
"weight": 1,
|
|
1097
|
-
"elements": [{
|
|
1098
|
-
"tag": "button",
|
|
1099
|
-
"text": {"tag": "plain_text", "content": "📖 帮助"},
|
|
1100
|
-
"type": "default",
|
|
1101
|
-
"behaviors": [{"type": "callback", "value": {"action": "menu_help"}}]
|
|
1102
|
-
}]
|
|
1103
|
-
},
|
|
1104
1135
|
{
|
|
1105
1136
|
"tag": "column",
|
|
1106
1137
|
"width": "weighted",
|
|
@@ -22,6 +22,23 @@ from lark_oapi.api.cardkit.v1 import (
|
|
|
22
22
|
|
|
23
23
|
from . import config
|
|
24
24
|
|
|
25
|
+
|
|
26
|
+
def _is_element_limit_error(msg: str) -> bool:
|
|
27
|
+
"""判断飞书 API 返回的错误是否为元素超限"""
|
|
28
|
+
if not msg:
|
|
29
|
+
return False
|
|
30
|
+
lower = msg.lower()
|
|
31
|
+
return "element exceeds" in lower or "超限" in lower
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _ElementLimitResult:
|
|
35
|
+
"""元素超限的哨兵返回值,__bool__ 为 False 兼容现有 if not success 逻辑"""
|
|
36
|
+
is_element_limit = True
|
|
37
|
+
|
|
38
|
+
def __bool__(self):
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
25
42
|
import sys as _sys
|
|
26
43
|
_sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent.parent))
|
|
27
44
|
try:
|
|
@@ -193,6 +210,10 @@ class CardService:
|
|
|
193
210
|
return True
|
|
194
211
|
else:
|
|
195
212
|
logger.warning(f"更新卡片失败(attempt={attempt+1}): card_id={card_id} seq={sequence} code={response.code} msg={response.msg}")
|
|
213
|
+
if _is_element_limit_error(response.msg):
|
|
214
|
+
# 元素超限是内容问题,重试无意义,直接返回哨兵值
|
|
215
|
+
logger.warning(f"检测到元素超限错误,跳过重试: card_id={card_id}")
|
|
216
|
+
return _ElementLimitResult()
|
|
196
217
|
except Exception as e:
|
|
197
218
|
logger.error(f"更新卡片异常(attempt={attempt+1}): card_id={card_id} seq={sequence} error={e}")
|
|
198
219
|
|
|
@@ -26,7 +26,6 @@ from .card_builder import (
|
|
|
26
26
|
build_help_card,
|
|
27
27
|
build_dir_card,
|
|
28
28
|
build_menu_card,
|
|
29
|
-
build_session_closed_card,
|
|
30
29
|
)
|
|
31
30
|
from .shared_memory_poller import SharedMemoryPoller, CardSlice
|
|
32
31
|
|
|
@@ -92,11 +91,17 @@ class LarkHandler:
|
|
|
92
91
|
|
|
93
92
|
async def _attach(self, chat_id: str, session_name: str) -> bool:
|
|
94
93
|
"""统一 attach 逻辑(私聊/群聊共用)"""
|
|
94
|
+
# 在断开旧连接之前,先更新旧流式卡片为已断开状态
|
|
95
|
+
old_session = self._chat_sessions.get(chat_id)
|
|
96
|
+
old_slice = self._poller.stop_and_get_active_slice(chat_id)
|
|
97
|
+
if old_slice and old_session:
|
|
98
|
+
await self._update_card_disconnected(chat_id, old_session, old_slice)
|
|
99
|
+
|
|
95
100
|
# 断开旧 bridge
|
|
96
101
|
old = self._bridges.pop(chat_id, None)
|
|
97
102
|
if old:
|
|
98
103
|
await old.disconnect()
|
|
99
|
-
|
|
104
|
+
# _poller.stop 已通过 stop_and_get_active_slice 完成
|
|
100
105
|
self._chat_sessions.pop(chat_id, None)
|
|
101
106
|
self._detached_slices.pop(chat_id, None)
|
|
102
107
|
|
|
@@ -132,19 +137,8 @@ class LarkHandler:
|
|
|
132
137
|
self._detached_slices.pop(chat_id, None)
|
|
133
138
|
self._remove_binding_by_chat(chat_id)
|
|
134
139
|
|
|
135
|
-
card = build_session_closed_card(session_name)
|
|
136
140
|
if active_slice:
|
|
137
|
-
|
|
138
|
-
success = await card_service.update_card(
|
|
139
|
-
card_id=active_slice.card_id,
|
|
140
|
-
sequence=active_slice.sequence + 1,
|
|
141
|
-
card_content=card,
|
|
142
|
-
)
|
|
143
|
-
if success:
|
|
144
|
-
return
|
|
145
|
-
except Exception as e:
|
|
146
|
-
logger.warning(f"_on_disconnect 就地更新失败: {e}")
|
|
147
|
-
await card_service.create_and_send_card(chat_id, card)
|
|
141
|
+
await self._update_card_disconnected(chat_id, session_name, active_slice)
|
|
148
142
|
|
|
149
143
|
# ── 消息入口 ────────────────────────────────────────────────────────────
|
|
150
144
|
|
|
@@ -398,6 +392,9 @@ class LarkHandler:
|
|
|
398
392
|
# 断开所有连接到此会话的 chat
|
|
399
393
|
for cid, sname in list(self._chat_sessions.items()):
|
|
400
394
|
if sname == session_name:
|
|
395
|
+
active_slice = self._poller.stop_and_get_active_slice(cid)
|
|
396
|
+
if active_slice:
|
|
397
|
+
await self._update_card_disconnected(cid, sname, active_slice)
|
|
401
398
|
await self._detach(cid)
|
|
402
399
|
self._remove_binding_by_chat(cid)
|
|
403
400
|
|
|
@@ -410,10 +407,44 @@ class LarkHandler:
|
|
|
410
407
|
async def _handle_list_detach(self, user_id: str, chat_id: str,
|
|
411
408
|
message_id: Optional[str] = None):
|
|
412
409
|
"""会话列表卡片中断开连接,就地刷新列表"""
|
|
410
|
+
session_name = self._chat_sessions.get(chat_id, "")
|
|
411
|
+
# 更新流式卡片为已断开状态
|
|
412
|
+
active_slice = self._poller.stop_and_get_active_slice(chat_id)
|
|
413
|
+
if active_slice and session_name:
|
|
414
|
+
await self._update_card_disconnected(chat_id, session_name, active_slice)
|
|
415
|
+
|
|
413
416
|
self._remove_binding_by_chat(chat_id)
|
|
414
|
-
await self._detach(chat_id)
|
|
417
|
+
await self._detach(chat_id) # bridge.disconnect + _poller.stop(幂等)
|
|
415
418
|
await self._cmd_list(user_id, chat_id, message_id=message_id)
|
|
416
419
|
|
|
420
|
+
async def _update_card_disconnected(self, chat_id: str, session_name: str,
|
|
421
|
+
active_slice: 'CardSlice') -> bool:
|
|
422
|
+
"""读取最新 blocks 并就地更新卡片为断开状态(disconnected=True)。Best-effort,不降级发新卡。"""
|
|
423
|
+
blocks = []
|
|
424
|
+
try:
|
|
425
|
+
import sys as _sys
|
|
426
|
+
_sys.path.insert(0, str(Path(__file__).parent.parent / "server"))
|
|
427
|
+
from shared_state import SharedStateReader, get_mq_path
|
|
428
|
+
mq_path = get_mq_path(session_name)
|
|
429
|
+
if mq_path.exists():
|
|
430
|
+
reader = SharedStateReader(session_name)
|
|
431
|
+
state = reader.read()
|
|
432
|
+
reader.close()
|
|
433
|
+
blocks = state.get("blocks", [])
|
|
434
|
+
except Exception:
|
|
435
|
+
pass
|
|
436
|
+
blocks_slice = blocks[active_slice.start_idx:]
|
|
437
|
+
card = build_stream_card(blocks_slice, disconnected=True, session_name=session_name)
|
|
438
|
+
try:
|
|
439
|
+
return await card_service.update_card(
|
|
440
|
+
card_id=active_slice.card_id,
|
|
441
|
+
sequence=active_slice.sequence + 1,
|
|
442
|
+
card_content=card,
|
|
443
|
+
)
|
|
444
|
+
except Exception as e:
|
|
445
|
+
logger.warning(f"_update_card_disconnected 失败 ({chat_id[:8]}...): {e}")
|
|
446
|
+
return False
|
|
447
|
+
|
|
417
448
|
async def _handle_stream_detach(self, user_id: str, chat_id: str,
|
|
418
449
|
session_name: str, message_id: Optional[str] = None):
|
|
419
450
|
"""流式卡片中断开连接,就地更新卡片为已断开状态"""
|
|
@@ -500,7 +531,7 @@ class LarkHandler:
|
|
|
500
531
|
await self._send_or_update_card(chat_id, card, message_id)
|
|
501
532
|
|
|
502
533
|
async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
|
|
503
|
-
tree: bool = False, message_id: Optional[str] = None):
|
|
534
|
+
tree: bool = False, message_id: Optional[str] = None, page: int = 0):
|
|
504
535
|
"""查看目录文件结构"""
|
|
505
536
|
all_sessions = list_active_sessions()
|
|
506
537
|
sessions_info = []
|
|
@@ -540,10 +571,11 @@ class LarkHandler:
|
|
|
540
571
|
return
|
|
541
572
|
|
|
542
573
|
session_groups = {sname: cid for cid, sname in self._chat_bindings.items() if cid.startswith("oc_")}
|
|
543
|
-
card = build_dir_card(target, entries, sessions_info, tree=tree, session_groups=session_groups)
|
|
574
|
+
card = build_dir_card(target, entries, sessions_info, tree=tree, session_groups=session_groups, page=page)
|
|
544
575
|
await self._send_or_update_card(chat_id, card, message_id)
|
|
545
576
|
|
|
546
|
-
async def _cmd_new_group(self, user_id: str, chat_id: str, args: str
|
|
577
|
+
async def _cmd_new_group(self, user_id: str, chat_id: str, args: str,
|
|
578
|
+
message_id: Optional[str] = None):
|
|
547
579
|
"""创建专属群聊并绑定 Claude 会话"""
|
|
548
580
|
session_name = args.strip()
|
|
549
581
|
if not session_name:
|
|
@@ -606,6 +638,8 @@ class LarkHandler:
|
|
|
606
638
|
f"✅ 已创建专属群「【{dir_label}】{config.BOT_NAME}」并已连接\n"
|
|
607
639
|
f"在群内直接发消息即可与 Claude 交互"
|
|
608
640
|
)
|
|
641
|
+
# 刷新会话列表卡片,使"创建群聊"按钮变为"进入群聊"
|
|
642
|
+
await self._cmd_list(user_id, chat_id, message_id=message_id)
|
|
609
643
|
except Exception as e:
|
|
610
644
|
logger.error(f"创建群失败: {e}")
|
|
611
645
|
await card_service.send_text(chat_id, f"创建群失败:{e}")
|
|
@@ -623,6 +657,7 @@ class LarkHandler:
|
|
|
623
657
|
|
|
624
658
|
import json as _json
|
|
625
659
|
import urllib.request
|
|
660
|
+
import urllib.error
|
|
626
661
|
from . import config
|
|
627
662
|
try:
|
|
628
663
|
token_resp = urllib.request.urlopen(
|
|
@@ -635,20 +670,39 @@ class LarkHandler:
|
|
|
635
670
|
)
|
|
636
671
|
token = _json.loads(token_resp.read())["tenant_access_token"]
|
|
637
672
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
673
|
+
feishu_ok = False
|
|
674
|
+
feishu_msg = ""
|
|
675
|
+
try:
|
|
676
|
+
disband_resp = urllib.request.urlopen(
|
|
677
|
+
urllib.request.Request(
|
|
678
|
+
f"https://open.feishu.cn/open-apis/im/v1/chats/{group_chat_id}",
|
|
679
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
680
|
+
method="DELETE"
|
|
681
|
+
), timeout=10
|
|
682
|
+
)
|
|
683
|
+
disband_data = _json.loads(disband_resp.read())
|
|
684
|
+
feishu_ok = disband_data.get("code") == 0
|
|
685
|
+
feishu_msg = disband_data.get("msg", "")
|
|
686
|
+
except urllib.error.HTTPError as e:
|
|
687
|
+
err_body = e.read().decode("utf-8", errors="replace")
|
|
688
|
+
try:
|
|
689
|
+
err_data = _json.loads(err_body)
|
|
690
|
+
feishu_ok = False
|
|
691
|
+
feishu_msg = f"code={err_data.get('code')} {err_data.get('msg', '')}"
|
|
692
|
+
except Exception:
|
|
693
|
+
feishu_ok = False
|
|
694
|
+
feishu_msg = f"HTTP {e.code}"
|
|
695
|
+
logger.error(f"解散群 API 失败: {feishu_msg}")
|
|
696
|
+
|
|
697
|
+
# 无论 Feishu delete 是否成功,都清理本地绑定
|
|
650
698
|
self._remove_binding_by_chat(group_chat_id)
|
|
651
699
|
await self._detach(group_chat_id)
|
|
700
|
+
|
|
701
|
+
if feishu_ok:
|
|
702
|
+
notice = "✅ 群聊已解散,绑定已解除"
|
|
703
|
+
else:
|
|
704
|
+
notice = f"⚠️ Feishu 群解散失败({feishu_msg}),已解除本地绑定。如需彻底解散请在飞书群内手动操作"
|
|
705
|
+
await card_service.send_text(chat_id, notice)
|
|
652
706
|
await self._cmd_list(user_id, chat_id, message_id=message_id)
|
|
653
707
|
except Exception as e:
|
|
654
708
|
logger.error(f"解散群失败: {e}")
|
|
@@ -800,7 +854,7 @@ class LarkHandler:
|
|
|
800
854
|
})
|
|
801
855
|
except PermissionError:
|
|
802
856
|
pass
|
|
803
|
-
return entries
|
|
857
|
+
return entries
|
|
804
858
|
|
|
805
859
|
@staticmethod
|
|
806
860
|
def _collect_tree_entries(target, max_depth: int = 2, max_items: int = 60) -> list:
|
|
@@ -830,6 +884,15 @@ class LarkHandler:
|
|
|
830
884
|
_walk(target, 0)
|
|
831
885
|
return entries
|
|
832
886
|
|
|
887
|
+
async def disconnect_all_for_shutdown(self) -> None:
|
|
888
|
+
"""lark stop 时清理所有活跃流式卡片(更新为已断开状态)"""
|
|
889
|
+
chat_ids = list(self._bridges.keys())
|
|
890
|
+
for chat_id in chat_ids:
|
|
891
|
+
session_name = self._chat_sessions.get(chat_id, "")
|
|
892
|
+
active_slice = self._poller.stop_and_get_active_slice(chat_id)
|
|
893
|
+
if active_slice and session_name:
|
|
894
|
+
await self._update_card_disconnected(chat_id, session_name, active_slice)
|
|
895
|
+
|
|
833
896
|
@staticmethod
|
|
834
897
|
def _get_pid_cwd(pid: int) -> Optional[str]:
|
|
835
898
|
"""获取进程的工作目录(macOS/Linux)"""
|
package/lark_client/main.py
CHANGED
|
@@ -30,6 +30,17 @@ from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
|
|
30
30
|
from . import config
|
|
31
31
|
from .lark_handler import handler
|
|
32
32
|
|
|
33
|
+
|
|
34
|
+
async def _graceful_shutdown() -> None:
|
|
35
|
+
"""优雅关闭:更新所有活跃流式卡片为已断开状态后退出"""
|
|
36
|
+
try:
|
|
37
|
+
await handler.disconnect_all_for_shutdown()
|
|
38
|
+
except Exception as e:
|
|
39
|
+
print(f"[Lark] graceful shutdown 异常: {e}")
|
|
40
|
+
finally:
|
|
41
|
+
import os
|
|
42
|
+
os._exit(0)
|
|
43
|
+
|
|
33
44
|
def check_user_allowed(user_id: str) -> bool:
|
|
34
45
|
"""检查用户是否在白名单中"""
|
|
35
46
|
if not config.ENABLE_USER_WHITELIST:
|
|
@@ -148,7 +159,7 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
|
|
|
148
159
|
if action_type == "list_new_group":
|
|
149
160
|
session_name = action_value.get("session", "")
|
|
150
161
|
print(f"[Lark] list_new_group: session={session_name}")
|
|
151
|
-
asyncio.create_task(handler._cmd_new_group(user_id, chat_id, session_name))
|
|
162
|
+
asyncio.create_task(handler._cmd_new_group(user_id, chat_id, session_name, message_id=message_id))
|
|
152
163
|
return None
|
|
153
164
|
|
|
154
165
|
# 列表卡片:解散群聊
|
|
@@ -165,6 +176,14 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
|
|
|
165
176
|
asyncio.create_task(handler._cmd_ls(user_id, chat_id, path, message_id=message_id))
|
|
166
177
|
return None
|
|
167
178
|
|
|
179
|
+
# 目录卡片:翻页
|
|
180
|
+
if action_type == "dir_page":
|
|
181
|
+
path = action_value.get("path", "")
|
|
182
|
+
page = int(action_value.get("page", 0))
|
|
183
|
+
print(f"[Lark] dir_page: path={path}, page={page}")
|
|
184
|
+
asyncio.create_task(handler._cmd_ls(user_id, chat_id, path, message_id=message_id, page=page))
|
|
185
|
+
return None
|
|
186
|
+
|
|
168
187
|
# 目录卡片:在该目录创建新 Claude 会话
|
|
169
188
|
if action_type == "dir_start":
|
|
170
189
|
path = action_value.get("path", "")
|
|
@@ -219,8 +238,13 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
|
|
|
219
238
|
# 快捷键按钮(callback 模式)
|
|
220
239
|
if action_type == "send_key":
|
|
221
240
|
key_name = action_value.get("key", "")
|
|
222
|
-
|
|
223
|
-
|
|
241
|
+
times = action_value.get("times", 1)
|
|
242
|
+
print(f"[Lark] send_key: key={key_name}" + (f" ×{times}" if times > 1 else ""))
|
|
243
|
+
async def _multi_send(k=key_name, t=times):
|
|
244
|
+
for _ in range(t):
|
|
245
|
+
await handler.send_raw_key(user_id, chat_id, k)
|
|
246
|
+
await asyncio.sleep(0.15)
|
|
247
|
+
asyncio.create_task(_multi_send())
|
|
224
248
|
return None
|
|
225
249
|
|
|
226
250
|
# 各卡片底部菜单按钮:辅助卡片就地→菜单,流式卡片降级新卡
|
|
@@ -288,12 +312,20 @@ class LarkBot:
|
|
|
288
312
|
self.ws_client.start()
|
|
289
313
|
|
|
290
314
|
def _signal_handler(self, signum, frame):
|
|
291
|
-
"""
|
|
315
|
+
"""处理退出信号(SIGTERM / SIGINT)"""
|
|
292
316
|
print("\n正在关闭...")
|
|
293
317
|
self.running = False
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
318
|
+
# 调度异步清理(更新所有活跃卡片为已断开状态后退出)
|
|
319
|
+
try:
|
|
320
|
+
loop = asyncio.get_event_loop()
|
|
321
|
+
if loop.is_running():
|
|
322
|
+
loop.call_soon_threadsafe(
|
|
323
|
+
lambda: asyncio.ensure_future(_graceful_shutdown())
|
|
324
|
+
)
|
|
325
|
+
return
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
sys.exit(0)
|
|
297
329
|
|
|
298
330
|
|
|
299
331
|
def main():
|
|
@@ -247,7 +247,13 @@ class SharedMemoryPoller:
|
|
|
247
247
|
card_content=card_dict,
|
|
248
248
|
)
|
|
249
249
|
|
|
250
|
-
if
|
|
250
|
+
if getattr(success, 'is_element_limit', False):
|
|
251
|
+
# 元素超限:冻结旧卡 + 推新流式卡
|
|
252
|
+
await self._handle_element_limit(
|
|
253
|
+
tracker, blocks, status_line, bottom_bar, agent_panel, option_block
|
|
254
|
+
)
|
|
255
|
+
return
|
|
256
|
+
elif not success:
|
|
251
257
|
# 降级:创建新卡片替代
|
|
252
258
|
logger.warning(
|
|
253
259
|
f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
|
|
@@ -305,6 +311,48 @@ class SharedMemoryPoller:
|
|
|
305
311
|
else:
|
|
306
312
|
logger.warning(f"create_card 失败 session={tracker.session_name}")
|
|
307
313
|
|
|
314
|
+
async def _handle_element_limit(
|
|
315
|
+
self, tracker: StreamTracker, blocks: List[dict],
|
|
316
|
+
status_line: Optional[dict], bottom_bar: Optional[dict],
|
|
317
|
+
agent_panel: Optional[dict] = None,
|
|
318
|
+
option_block: Optional[dict] = None,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""元素超限:冻结旧卡片 + 推送新流式卡片"""
|
|
321
|
+
active = tracker.cards[-1]
|
|
322
|
+
logger.warning(f"元素超限,冻结卡片 {active.card_id} 并推新卡")
|
|
323
|
+
|
|
324
|
+
# 1. 冻结旧卡片(灰色 header,无状态区和按钮)
|
|
325
|
+
from .card_builder import build_stream_card
|
|
326
|
+
blocks_slice = blocks[active.start_idx:]
|
|
327
|
+
frozen_card = build_stream_card(blocks_slice, None, None, is_frozen=True)
|
|
328
|
+
active.sequence += 1
|
|
329
|
+
await self._card_service.update_card(active.card_id, active.sequence, frozen_card)
|
|
330
|
+
active.frozen = True
|
|
331
|
+
_track_stats('card', 'freeze', session_name=tracker.session_name,
|
|
332
|
+
chat_id=tracker.chat_id)
|
|
333
|
+
|
|
334
|
+
# 2. 创建新流式卡片,从最近 INITIAL_WINDOW 个 blocks 开始(重置窗口)
|
|
335
|
+
new_start = max(0, len(blocks) - INITIAL_WINDOW)
|
|
336
|
+
new_blocks = blocks[new_start:]
|
|
337
|
+
if not new_blocks and not status_line and not bottom_bar:
|
|
338
|
+
return
|
|
339
|
+
new_card_dict = build_stream_card(
|
|
340
|
+
new_blocks, status_line, bottom_bar,
|
|
341
|
+
agent_panel=agent_panel, option_block=option_block,
|
|
342
|
+
session_name=tracker.session_name,
|
|
343
|
+
)
|
|
344
|
+
new_card_id = await self._card_service.create_card(new_card_dict)
|
|
345
|
+
if new_card_id:
|
|
346
|
+
await self._card_service.send_card(tracker.chat_id, new_card_id)
|
|
347
|
+
tracker.cards.append(CardSlice(card_id=new_card_id, start_idx=new_start))
|
|
348
|
+
tracker.content_hash = self._compute_hash(new_blocks, status_line, bottom_bar, agent_panel, option_block)
|
|
349
|
+
_track_stats('card', 'create', session_name=tracker.session_name,
|
|
350
|
+
chat_id=tracker.chat_id)
|
|
351
|
+
logger.info(
|
|
352
|
+
f"[ELEMENT_LIMIT_SPLIT] session={tracker.session_name} "
|
|
353
|
+
f"new_start={new_start} blocks={len(new_blocks)} card_id={new_card_id}"
|
|
354
|
+
)
|
|
355
|
+
|
|
308
356
|
async def _freeze_and_split(
|
|
309
357
|
self, tracker: StreamTracker, blocks: List[dict],
|
|
310
358
|
status_line: Optional[dict], bottom_bar: Optional[dict],
|
package/package.json
CHANGED
package/server/server.py
CHANGED
|
@@ -624,22 +624,22 @@ class ProxyServer:
|
|
|
624
624
|
raise
|
|
625
625
|
|
|
626
626
|
if pid == 0:
|
|
627
|
-
#
|
|
628
|
-
for k, v in _extra_env.items():
|
|
629
|
-
os.environ[k] = v
|
|
630
|
-
# 环境已加载到内存,立即删除快照文件(execvp 前销毁)
|
|
627
|
+
# 环境已加载到内存,立即删除快照文件(exec 前销毁)
|
|
631
628
|
try:
|
|
632
629
|
env_snapshot_path.unlink()
|
|
633
630
|
except Exception:
|
|
634
631
|
pass
|
|
632
|
+
# 以快照为权威来源完整替换子进程环境,确保 unset 的变量也消失
|
|
633
|
+
# 若 snapshot 加载失败(_extra_env 为空),降级使用当前进程环境
|
|
634
|
+
child_env = dict(_extra_env) if _extra_env else dict(os.environ)
|
|
635
635
|
# 恢复 TERM 以支持 kitty keyboard protocol(Shift+Enter 等扩展键)
|
|
636
636
|
# tmux 会将 TERM 改为 tmux-256color,导致 Claude CLI 不启用 kitty protocol
|
|
637
|
-
|
|
637
|
+
child_env['TERM'] = 'xterm-256color'
|
|
638
638
|
# 清除 tmux 标识变量(PTY 数据不经过 tmux,不应让 Claude CLI 误判终端环境)
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
os.
|
|
642
|
-
os._exit(1) #
|
|
639
|
+
child_env.pop('TMUX', None)
|
|
640
|
+
child_env.pop('TMUX_PANE', None)
|
|
641
|
+
os.execvpe("claude", ["claude"] + self.claude_args, child_env)
|
|
642
|
+
os._exit(1) # execvpe 失败时兜底退出
|
|
643
643
|
else:
|
|
644
644
|
# 父进程
|
|
645
645
|
self.master_fd = fd
|