@researai/deepscientist 1.5.8 → 1.5.11

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.
Files changed (148) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +108 -95
  3. package/assets/branding/connector-qq.png +0 -0
  4. package/assets/branding/connector-rokid.png +0 -0
  5. package/assets/branding/connector-weixin.png +0 -0
  6. package/assets/branding/projects.png +0 -0
  7. package/bin/ds.js +172 -13
  8. package/docs/assets/branding/projects.png +0 -0
  9. package/docs/en/00_QUICK_START.md +308 -70
  10. package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
  11. package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
  12. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  13. package/docs/en/09_DOCTOR.md +41 -5
  14. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  15. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  16. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
  17. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  18. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +79 -0
  21. package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
  22. package/docs/images/weixin/weixin-plugin-entry.png +0 -0
  23. package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
  24. package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
  25. package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
  26. package/docs/images/weixin/weixin-settings-bind.svg +57 -0
  27. package/docs/zh/00_QUICK_START.md +315 -74
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +41 -5
  32. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
  33. package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
  34. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
  35. package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
  36. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  37. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  38. package/docs/zh/README.md +126 -0
  39. package/install.sh +0 -34
  40. package/package.json +3 -3
  41. package/pyproject.toml +2 -2
  42. package/src/deepscientist/__init__.py +1 -1
  43. package/src/deepscientist/annotations.py +343 -0
  44. package/src/deepscientist/artifact/arxiv.py +484 -37
  45. package/src/deepscientist/artifact/metrics.py +1 -3
  46. package/src/deepscientist/artifact/service.py +1347 -111
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/service.py +9 -0
  49. package/src/deepscientist/bridges/builtins.py +2 -0
  50. package/src/deepscientist/bridges/connectors.py +447 -0
  51. package/src/deepscientist/channels/__init__.py +2 -0
  52. package/src/deepscientist/channels/builtins.py +3 -1
  53. package/src/deepscientist/channels/qq.py +1 -1
  54. package/src/deepscientist/channels/qq_gateway.py +1 -1
  55. package/src/deepscientist/channels/relay.py +7 -1
  56. package/src/deepscientist/channels/weixin.py +59 -0
  57. package/src/deepscientist/channels/weixin_ilink.py +317 -0
  58. package/src/deepscientist/config/models.py +22 -2
  59. package/src/deepscientist/config/service.py +431 -60
  60. package/src/deepscientist/connector/__init__.py +4 -0
  61. package/src/deepscientist/connector/connector_profiles.py +481 -0
  62. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  63. package/src/deepscientist/connector/qq_profiles.py +206 -0
  64. package/src/deepscientist/connector/weixin_support.py +663 -0
  65. package/src/deepscientist/connector_profiles.py +1 -374
  66. package/src/deepscientist/connector_runtime.py +2 -0
  67. package/src/deepscientist/daemon/api/handlers.py +295 -5
  68. package/src/deepscientist/daemon/api/router.py +16 -1
  69. package/src/deepscientist/daemon/app.py +1130 -61
  70. package/src/deepscientist/doctor.py +5 -2
  71. package/src/deepscientist/gitops/diff.py +120 -29
  72. package/src/deepscientist/lingzhu_support.py +1 -182
  73. package/src/deepscientist/mcp/server.py +14 -5
  74. package/src/deepscientist/prompts/builder.py +29 -1
  75. package/src/deepscientist/qq_profiles.py +1 -196
  76. package/src/deepscientist/quest/node_traces.py +152 -2
  77. package/src/deepscientist/quest/service.py +169 -43
  78. package/src/deepscientist/quest/stage_views.py +172 -9
  79. package/src/deepscientist/registries/baseline.py +56 -4
  80. package/src/deepscientist/runners/codex.py +55 -3
  81. package/src/deepscientist/weixin_support.py +1 -0
  82. package/src/prompts/connectors/lingzhu.md +3 -1
  83. package/src/prompts/connectors/weixin.md +230 -0
  84. package/src/prompts/system.md +9 -0
  85. package/src/skills/idea/SKILL.md +16 -0
  86. package/src/skills/idea/references/literature-survey-template.md +24 -0
  87. package/src/skills/idea/references/related-work-playbook.md +4 -0
  88. package/src/skills/idea/references/selection-gate.md +9 -0
  89. package/src/skills/write/SKILL.md +1 -1
  90. package/src/tui/package.json +1 -1
  91. package/src/ui/dist/assets/{AiManusChatView-m2FNtwbn.js → AiManusChatView-D0mTXG4-.js} +156 -48
  92. package/src/ui/dist/assets/{AnalysisPlugin-BMTF8EGL.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
  93. package/src/ui/dist/assets/{CliPlugin-BEOWgxCI.js → CliPlugin-DrV8je02.js} +164 -9
  94. package/src/ui/dist/assets/{CodeEditorPlugin-BCXvjqmb.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
  95. package/src/ui/dist/assets/{CodeViewerPlugin-DaJcy3nD.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
  96. package/src/ui/dist/assets/{DocViewerPlugin-ByfeIq4K.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
  97. package/src/ui/dist/assets/{GitDiffViewerPlugin-Cksf3VZ-.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
  98. package/src/ui/dist/assets/{ImageViewerPlugin-CFz-OsTS.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
  99. package/src/ui/dist/assets/{LabCopilotPanel-CJ1cJzoX.js → LabCopilotPanel-1qSow1es.js} +11 -11
  100. package/src/ui/dist/assets/{LabPlugin-BF3dVJwa.js → LabPlugin-eQpPPCEp.js} +2 -1
  101. package/src/ui/dist/assets/{LatexPlugin-DDkwZ6Sj.js → LatexPlugin-BwRfi89Z.js} +7 -7
  102. package/src/ui/dist/assets/{MarkdownViewerPlugin-HAuvurcT.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
  103. package/src/ui/dist/assets/{MarketplacePlugin-BtoTYy2C.js → MarketplacePlugin-C2y_556i.js} +3 -3
  104. package/src/ui/dist/assets/{NotebookEditor-CSJYx7b-.js → NotebookEditor-BRzJbGsn.js} +12 -12
  105. package/src/ui/dist/assets/{NotebookEditor-DQgRezm_.js → NotebookEditor-DIX7Mlzu.js} +1 -1
  106. package/src/ui/dist/assets/{PdfLoader-DPa_-fv6.js → PdfLoader-DzRaTAlq.js} +14 -7
  107. package/src/ui/dist/assets/{PdfMarkdownPlugin-BZpXOEjm.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
  108. package/src/ui/dist/assets/{PdfViewerPlugin-BT8a6wGR.js → PdfViewerPlugin-BwtICzue.js} +103 -34
  109. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  110. package/src/ui/dist/assets/{SearchPlugin-D_blveZi.js → SearchPlugin-DHeIAMsx.js} +1 -1
  111. package/src/ui/dist/assets/{TextViewerPlugin-Btx0M3hX.js → TextViewerPlugin-C3tCmFox.js} +5 -4
  112. package/src/ui/dist/assets/{VNCViewer-DImJO4rO.js → VNCViewer-CQsKVm3t.js} +10 -10
  113. package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
  114. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  115. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  116. package/src/ui/dist/assets/{code-BUfXGJSl.js → code-XfbSR8K2.js} +1 -1
  117. package/src/ui/dist/assets/{file-content-VqamwI3X.js → file-content-BjxNaIfy.js} +1 -1
  118. package/src/ui/dist/assets/{file-diff-panel-C_wOoS7a.js → file-diff-panel-D_lLVQk0.js} +1 -1
  119. package/src/ui/dist/assets/{file-socket-D2bTuMVP.js → file-socket-D9x_5vlY.js} +1 -1
  120. package/src/ui/dist/assets/{image-BZkGJ4mM.js → image-BhWT33W1.js} +1 -1
  121. package/src/ui/dist/assets/{index-DdRW6RMJ.js → index--c4iXtuy.js} +12 -12
  122. package/src/ui/dist/assets/{index-CxkvSeKw.js → index-BDxipwrC.js} +2 -2
  123. package/src/ui/dist/assets/{index-DjggJovS.js → index-DZTZ8mWP.js} +14934 -9613
  124. package/src/ui/dist/assets/{index-DXZ1daiJ.css → index-Dqj-Mjb4.css} +2 -13
  125. package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
  126. package/src/ui/dist/assets/{monaco-DHMc7kKM.js → monaco-K8izTGgo.js} +1 -1
  127. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
  128. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  129. package/src/ui/dist/assets/{popover-B85oCgCS.js → popover-yFK1J4fL.js} +1 -1
  130. package/src/ui/dist/assets/{project-sync-DOMCcPac.js → project-sync-PENr2zcz.js} +1 -74
  131. package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
  132. package/src/ui/dist/assets/{sigma-BO2rQrl3.js → sigma-DEuYJqTl.js} +1 -1
  133. package/src/ui/dist/assets/{index-D9QIGcmc.js → square-check-big-omoSUmcd.js} +2 -13
  134. package/src/ui/dist/assets/{trash-BsVEH_dV.js → trash--F119N47.js} +1 -1
  135. package/src/ui/dist/assets/{useCliAccess-b8L6JuZm.js → useCliAccess-D31UR23I.js} +1 -1
  136. package/src/ui/dist/assets/{useFileDiffOverlay-BY7uA9hV.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
  137. package/src/ui/dist/assets/{wrap-text-BwyVuUIK.js → wrap-text-CZ613PM5.js} +1 -1
  138. package/src/ui/dist/assets/{zoom-out-RDpLugQP.js → zoom-out-BgDLAv3z.js} +1 -1
  139. package/src/ui/dist/index.html +2 -2
  140. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  141. package/src/ui/dist/assets/AutoFigurePlugin-DxPdMUNb.js +0 -8149
  142. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  143. package/src/ui/dist/assets/Stepper-DH2k75Vo.js +0 -158
  144. package/src/ui/dist/assets/bibtex-B-Hqu0Sg.js +0 -189
  145. package/src/ui/dist/assets/file-utils--zJCPN1i.js +0 -109
  146. package/src/ui/dist/assets/message-square-FUIPIhU2.js +0 -16
  147. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  148. package/src/ui/dist/assets/tooltip-B1OspAkx.js +0 -108
@@ -4,6 +4,7 @@ import base64
4
4
  import json
5
5
  import mimetypes
6
6
  import os
7
+ import re
7
8
  import shutil
8
9
  import subprocess
9
10
  import threading
@@ -16,6 +17,7 @@ from urllib.parse import parse_qs, urlencode, urlparse
16
17
  from urllib.request import Request
17
18
 
18
19
  from .. import __version__
20
+ from ..annotations import AnnotationService
19
21
  from ..artifact import ArtifactService
20
22
  from ..bash_exec import BashExecService
21
23
  from ..bash_exec.runtime import TerminalClient
@@ -27,9 +29,10 @@ from ..channels.feishu_long_connection import FeishuLongConnectionService
27
29
  from ..channels.qq_gateway import QQGatewayService
28
30
  from ..channels.slack_socket import SlackSocketModeService
29
31
  from ..channels.telegram_polling import TelegramPollingService
32
+ from ..channels.weixin_ilink import WeixinIlinkService
30
33
  from ..channels.whatsapp_local_session import WhatsAppLocalSessionService
31
34
  from ..cloud import CloudLinkService
32
- from ..connector_profiles import (
35
+ from ..connector.connector_profiles import (
33
36
  CONNECTOR_PROFILE_SPECS,
34
37
  PROFILEABLE_CONNECTOR_NAMES,
35
38
  connector_profile_label,
@@ -44,15 +47,36 @@ from ..home import repo_root
44
47
  from ..memory import MemoryService
45
48
  from ..network import urlopen_with_proxy as urlopen
46
49
  from ..latex_runtime import QuestLatexService
50
+ from ..connector.lingzhu_support import (
51
+ lingzhu_detect_tool_call_from_text,
52
+ lingzhu_extract_task_text,
53
+ lingzhu_extract_user_text,
54
+ lingzhu_health_payload,
55
+ lingzhu_is_passive_conversation_id,
56
+ lingzhu_passive_conversation_id,
57
+ lingzhu_request_conversation_id,
58
+ lingzhu_request_sender_id,
59
+ lingzhu_sse_answer,
60
+ lingzhu_sse_tool_call,
61
+ lingzhu_surface_action_tool_call,
62
+ lingzhu_verify_auth_header,
63
+ )
47
64
  from ..prompts import PromptBuilder
48
65
  from ..prompts.builder import STANDARD_SKILLS
49
- from ..qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
66
+ from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
50
67
  from ..quest import QuestService
51
68
  from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
52
69
  from ..runtime_logs import JsonlLogger
53
70
  from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_text, resolve_within, run_command, slugify, utc_now, which, write_json
54
71
  from ..skills import SkillInstaller
55
72
  from ..team import SingleTeamService
73
+ from ..connector.weixin_support import (
74
+ DEFAULT_WEIXIN_BOT_TYPE,
75
+ fetch_weixin_qrcode,
76
+ normalize_weixin_base_url,
77
+ normalize_weixin_cdn_base_url,
78
+ poll_weixin_qrcode_status,
79
+ )
56
80
  from .api import ApiHandlers, match_route
57
81
  from .sessions import SessionStore
58
82
  from websockets.datastructures import Headers
@@ -70,6 +94,23 @@ CODEX_RETRY_DEFAULT_MAX_BACKOFF_SEC = 1800.0
70
94
  LEGACY_CODEX_RETRY_INITIAL_BACKOFF_SEC = 1.0
71
95
  LEGACY_CODEX_RETRY_BACKOFF_MULTIPLIER = 2.0
72
96
  LEGACY_CODEX_RETRY_MAX_BACKOFF_SEC = 8.0
97
+ _LINGZHU_SHORT_COMMAND_DIRECT_MAP = {
98
+ "帮助": "help",
99
+ "列表": "list",
100
+ "状态": "status",
101
+ "总结": "summary",
102
+ "图谱": "graph",
103
+ "指标": "metrics",
104
+ }
105
+ _LINGZHU_SHORT_COMMAND_PREFIX_MAP = {
106
+ "绑定": "use",
107
+ "新建": "new",
108
+ "删除": "delete",
109
+ "暂停": "stop",
110
+ "恢复": "resume",
111
+ }
112
+ _LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新"}
113
+ _LINGZHU_DELETE_CONFIRM_ALIASES = {"确认", "强制", "--yes", "-y"}
73
114
 
74
115
 
75
116
  class DaemonApp:
@@ -88,6 +129,7 @@ class DaemonApp:
88
129
  self.quest_service = QuestService(home, skill_installer=self.skill_installer)
89
130
  self.latex_service = QuestLatexService(self.quest_service)
90
131
  self.memory_service = MemoryService(home)
132
+ self.annotation_service = AnnotationService(home)
91
133
  self.artifact_service = ArtifactService(home)
92
134
  self.bash_exec_service = BashExecService(home)
93
135
  self.team_service = SingleTeamService(home)
@@ -127,6 +169,7 @@ class DaemonApp:
127
169
  }
128
170
  self.channels = {name: self._create_channel(name) for name in list_channel_names()}
129
171
  self.sessions = SessionStore()
172
+ self._canonicalize_lingzhu_binding_state()
130
173
  self._turn_lock = threading.Lock()
131
174
  self._turn_state: dict[str, dict[str, object]] = {}
132
175
  self._server: ThreadingHTTPServer | None = None
@@ -138,11 +181,13 @@ class DaemonApp:
138
181
  self._serve_port: int | None = None
139
182
  self._shutdown_requested = threading.Event()
140
183
  self._qq_gateways: dict[str, QQGatewayService] = {}
184
+ self._weixin_ilink: WeixinIlinkService | None = None
141
185
  self._telegram_polling: dict[str, TelegramPollingService] = {}
142
186
  self._slack_socket: dict[str, SlackSocketModeService] = {}
143
187
  self._discord_gateway: dict[str, DiscordGatewayService] = {}
144
188
  self._feishu_long_connection: dict[str, FeishuLongConnectionService] = {}
145
189
  self._whatsapp_local_session: dict[str, WhatsAppLocalSessionService] = {}
190
+ self._weixin_login_sessions: dict[str, dict[str, Any]] = {}
146
191
  self.handlers = ApiHandlers(self)
147
192
 
148
193
  def list_connector_statuses(self) -> list[dict[str, object]]:
@@ -150,10 +195,10 @@ class DaemonApp:
150
195
  items = [
151
196
  self._augment_connector_status(channel.status(), title_by_quest=title_by_quest)
152
197
  for name, channel in self.channels.items()
153
- if name == "local" or self._is_connector_system_enabled(name)
198
+ if name == "local" or (name != "lingzhu" and self._is_connector_system_enabled(name))
154
199
  ]
155
200
  lingzhu_config = self.connectors_config.get("lingzhu")
156
- if isinstance(lingzhu_config, dict) and self._is_connector_system_enabled("lingzhu"):
201
+ if isinstance(lingzhu_config, dict):
157
202
  items.append(self._augment_connector_status(self.config_manager.lingzhu_snapshot(lingzhu_config), title_by_quest=title_by_quest))
158
203
  return items
159
204
 
@@ -858,13 +903,15 @@ class DaemonApp:
858
903
  return {
859
904
  name
860
905
  for name in SYSTEM_CONNECTOR_NAMES
861
- if bool(system_enabled.get(name, name == "qq"))
906
+ if bool(system_enabled.get(name, name in {"qq", "weixin"}))
862
907
  }
863
908
 
864
909
  def _is_connector_system_enabled(self, connector_name: str) -> bool:
865
910
  normalized = str(connector_name or "").strip().lower()
866
911
  if normalized == "local":
867
912
  return True
913
+ if normalized == "lingzhu":
914
+ return True
868
915
  enabled = self._system_enabled_connector_names()
869
916
  if normalized in enabled:
870
917
  return True
@@ -999,10 +1046,13 @@ class DaemonApp:
999
1046
  preferred_connector_conversation_id: str | None = None,
1000
1047
  requested_connector_bindings: list[dict[str, object]] | None = None,
1001
1048
  force_connector_rebind: bool = True,
1049
+ auto_bind_latest_connectors: bool = True,
1002
1050
  requested_baseline_ref: dict[str, object] | None = None,
1003
1051
  startup_contract: dict[str, object] | None = None,
1004
1052
  ) -> dict:
1005
1053
  normalized_requested_bindings = self._normalize_requested_connector_bindings(requested_connector_bindings)
1054
+ if len(normalized_requested_bindings) > 1:
1055
+ raise ValueError("A quest may bind at most one external connector target.")
1006
1056
  snapshot = self.quest_service.create(
1007
1057
  goal=goal,
1008
1058
  title=title,
@@ -1126,7 +1176,7 @@ class DaemonApp:
1126
1176
  ),
1127
1177
  }
1128
1178
  )
1129
- else:
1179
+ elif auto_bind_latest_connectors:
1130
1180
  self._auto_bind_connectors_to_latest_quest(
1131
1181
  snapshot["quest_id"],
1132
1182
  goal=goal,
@@ -2370,6 +2420,176 @@ class DaemonApp:
2370
2420
  channel = self._channel_with_bindings(connector_name)
2371
2421
  return channel.list_bindings()
2372
2422
 
2423
+ def start_weixin_login_qr(self, *, force: bool = False) -> dict[str, Any]:
2424
+ connectors = self.config_manager.load_named_normalized("connectors")
2425
+ weixin = connectors.get("weixin") if isinstance(connectors.get("weixin"), dict) else {}
2426
+ base_url = normalize_weixin_base_url(weixin.get("base_url"))
2427
+ bot_type = str(weixin.get("bot_type") or DEFAULT_WEIXIN_BOT_TYPE).strip() or DEFAULT_WEIXIN_BOT_TYPE
2428
+ route_tag = str(weixin.get("route_tag") or "").strip() or None
2429
+ qr_payload = fetch_weixin_qrcode(base_url=base_url, bot_type=bot_type, route_tag=route_tag)
2430
+ qrcode_token = str(qr_payload.get("qrcode") or "").strip()
2431
+ qrcode_content = str(qr_payload.get("qrcode_img_content") or qr_payload.get("url") or "").strip()
2432
+ if not qrcode_token or not qrcode_content:
2433
+ raise RuntimeError("Weixin QR login did not return a valid qrcode token or renderable content.")
2434
+ session_key = generate_id("wxqr")
2435
+ self._weixin_login_sessions[session_key] = {
2436
+ "session_key": session_key,
2437
+ "qrcode": qrcode_token,
2438
+ "qrcode_content": qrcode_content,
2439
+ "base_url": base_url,
2440
+ "bot_type": bot_type,
2441
+ "route_tag": route_tag,
2442
+ "started_at": time.time(),
2443
+ "refresh_count": 0,
2444
+ "force": bool(force),
2445
+ }
2446
+ return {
2447
+ "ok": True,
2448
+ "session_key": session_key,
2449
+ "qrcode_content": qrcode_content,
2450
+ "qrcode_url": qrcode_content,
2451
+ "message": "Weixin QR code is ready. Scan it with WeChat to connect DeepScientist.",
2452
+ }
2453
+
2454
+ def wait_weixin_login_qr(self, *, session_key: str, timeout_ms: int = 1_500) -> dict[str, Any]:
2455
+ normalized_session_key = str(session_key or "").strip()
2456
+ if not normalized_session_key:
2457
+ return {
2458
+ "ok": False,
2459
+ "connected": False,
2460
+ "message": "Weixin QR session key is required.",
2461
+ }
2462
+ session = self._weixin_login_sessions.get(normalized_session_key)
2463
+ if not isinstance(session, dict):
2464
+ return {
2465
+ "ok": False,
2466
+ "connected": False,
2467
+ "message": "Weixin QR session was not found. Start a new login first.",
2468
+ }
2469
+
2470
+ deadline = time.time() + max(int(timeout_ms or 1_500), 500) / 1000.0
2471
+ while time.time() < deadline:
2472
+ remaining = max(deadline - time.time(), 1.0)
2473
+ try:
2474
+ status = poll_weixin_qrcode_status(
2475
+ base_url=str(session.get("base_url") or ""),
2476
+ qrcode=str(session.get("qrcode") or ""),
2477
+ route_tag=str(session.get("route_tag") or "").strip() or None,
2478
+ timeout=min(remaining, 35.0),
2479
+ )
2480
+ except Exception as exc:
2481
+ message = str(exc or "").strip().lower()
2482
+ if isinstance(exc, TimeoutError) or "timed out" in message or "timeout" in message:
2483
+ break
2484
+ raise
2485
+ state = str(status.get("status") or "wait").strip().lower() or "wait"
2486
+ session["status"] = state
2487
+ if state == "confirmed":
2488
+ return self._persist_weixin_login_session(session, status)
2489
+ if state == "expired":
2490
+ refreshed = self._refresh_weixin_login_session(session)
2491
+ return {
2492
+ "ok": True,
2493
+ "connected": False,
2494
+ "status": "expired",
2495
+ "session_key": normalized_session_key,
2496
+ "qrcode_content": refreshed.get("qrcode_content"),
2497
+ "qrcode_url": refreshed.get("qrcode_content"),
2498
+ "message": "Weixin QR code expired and was refreshed automatically.",
2499
+ }
2500
+ if state in {"scaned", "scanned"}:
2501
+ return {
2502
+ "ok": True,
2503
+ "connected": False,
2504
+ "status": "scaned",
2505
+ "session_key": normalized_session_key,
2506
+ "qrcode_content": str(session.get("qrcode_content") or "").strip() or None,
2507
+ "qrcode_url": str(session.get("qrcode_content") or "").strip() or None,
2508
+ "message": "QR code scanned. Confirm the login inside WeChat.",
2509
+ }
2510
+ return {
2511
+ "ok": True,
2512
+ "connected": False,
2513
+ "status": str(session.get("status") or "wait").strip() or "wait",
2514
+ "session_key": normalized_session_key,
2515
+ "qrcode_content": str(session.get("qrcode_content") or "").strip() or None,
2516
+ "qrcode_url": str(session.get("qrcode_content") or "").strip() or None,
2517
+ "message": "Waiting for Weixin QR confirmation.",
2518
+ }
2519
+
2520
+ def _refresh_weixin_login_session(self, session: dict[str, Any]) -> dict[str, Any]:
2521
+ qr_payload = fetch_weixin_qrcode(
2522
+ base_url=str(session.get("base_url") or ""),
2523
+ bot_type=str(session.get("bot_type") or DEFAULT_WEIXIN_BOT_TYPE),
2524
+ route_tag=str(session.get("route_tag") or "").strip() or None,
2525
+ )
2526
+ session["qrcode"] = str(qr_payload.get("qrcode") or "").strip()
2527
+ session["qrcode_content"] = str(qr_payload.get("qrcode_img_content") or qr_payload.get("url") or "").strip()
2528
+ session["started_at"] = time.time()
2529
+ session["refresh_count"] = int(session.get("refresh_count") or 0) + 1
2530
+ return session
2531
+
2532
+ def _persist_weixin_login_session(self, session: dict[str, Any], status: dict[str, Any]) -> dict[str, Any]:
2533
+ bot_token = str(status.get("bot_token") or "").strip()
2534
+ account_id = str(status.get("ilink_bot_id") or "").strip()
2535
+ login_user_id = str(status.get("ilink_user_id") or "").strip() or None
2536
+ if not bot_token or not account_id:
2537
+ return {
2538
+ "ok": False,
2539
+ "connected": False,
2540
+ "status": "confirmed",
2541
+ "message": "Weixin QR login confirmed, but the platform did not return `bot_token` or `ilink_bot_id`.",
2542
+ }
2543
+ connectors = self.config_manager.load_named_normalized("connectors")
2544
+ weixin = connectors.get("weixin") if isinstance(connectors.get("weixin"), dict) else {}
2545
+ weixin.update(
2546
+ {
2547
+ "enabled": True,
2548
+ "transport": "ilink_long_poll",
2549
+ "base_url": normalize_weixin_base_url(status.get("baseurl") or session.get("base_url")),
2550
+ "cdn_base_url": normalize_weixin_cdn_base_url(weixin.get("cdn_base_url")),
2551
+ "bot_type": str(session.get("bot_type") or DEFAULT_WEIXIN_BOT_TYPE),
2552
+ "bot_token": bot_token,
2553
+ "account_id": account_id,
2554
+ "login_user_id": login_user_id,
2555
+ }
2556
+ )
2557
+ connectors["weixin"] = weixin
2558
+ save_result = self.config_manager.save_named_payload("connectors", connectors)
2559
+ if not bool(save_result.get("ok")):
2560
+ self.logger.log(
2561
+ "warning",
2562
+ "connector.weixin_qr_persist_failed",
2563
+ session_key=str(session.get("session_key") or ""),
2564
+ account_id=account_id,
2565
+ errors=save_result.get("errors") or [],
2566
+ warnings=save_result.get("warnings") or [],
2567
+ )
2568
+ return {
2569
+ "ok": False,
2570
+ "connected": False,
2571
+ "status": "confirmed",
2572
+ "errors": save_result.get("errors") or [],
2573
+ "warnings": save_result.get("warnings") or [],
2574
+ "message": "Weixin login succeeded, but DeepScientist could not persist the connector config.",
2575
+ }
2576
+ self.reload_connectors_config()
2577
+ self._weixin_login_sessions.pop(str(session.get("session_key") or ""), None)
2578
+ snapshot = next(
2579
+ (item for item in self.list_connector_statuses() if str(item.get("name") or "").strip().lower() == "weixin"),
2580
+ None,
2581
+ )
2582
+ return {
2583
+ "ok": True,
2584
+ "connected": True,
2585
+ "status": "confirmed",
2586
+ "account_id": account_id,
2587
+ "login_user_id": login_user_id,
2588
+ "base_url": str(weixin.get("base_url") or "").strip() or None,
2589
+ "snapshot": snapshot,
2590
+ "message": "Weixin login succeeded and the connector config was saved.",
2591
+ }
2592
+
2373
2593
  def delete_connector_profile(self, connector_name: str, profile_id: str) -> dict | tuple[int, dict]:
2374
2594
  normalized_connector = str(connector_name or "").strip().lower()
2375
2595
  normalized_profile_id = str(profile_id or "").strip()
@@ -2701,8 +2921,9 @@ class DaemonApp:
2701
2921
  connector_name = str(parsed.get("connector") or "").strip().lower()
2702
2922
  if not connector_name or connector_name == "local" or connector_name not in self.channels:
2703
2923
  return 400, {"ok": False, "message": f"Unknown connector `{connector_name}` for conversation `{normalized}`."}
2924
+ binding_conversation_id = self._logical_connector_binding_conversation(connector_name, normalized)
2704
2925
  channel = self._channel_with_bindings(connector_name)
2705
- conflicts = self._inspect_connector_binding_conflicts(quest_id, normalized)
2926
+ conflicts = self._inspect_connector_binding_conflicts(quest_id, binding_conversation_id)
2706
2927
  if conflicts and not force:
2707
2928
  return 409, {
2708
2929
  "ok": False,
@@ -2710,23 +2931,23 @@ class DaemonApp:
2710
2931
  "message": "Conversation is already bound to another quest.",
2711
2932
  "quest_id": quest_id,
2712
2933
  "connector": connector_name,
2713
- "conversation_id": normalized,
2934
+ "conversation_id": binding_conversation_id,
2714
2935
  "conflicts": conflicts,
2715
2936
  }
2716
- existing_bound = channel.resolve_bound_quest(normalized)
2937
+ existing_bound = channel.resolve_bound_quest(binding_conversation_id)
2717
2938
  for item in conflicts:
2718
2939
  other_id = str(item.get("quest_id") or "").strip()
2719
2940
  if other_id and other_id != quest_id:
2720
- self.quest_service.unbind_source(other_id, normalized)
2721
- self.sessions.unbind(other_id, normalized)
2722
- channel.bind_conversation(normalized, quest_id)
2723
- self.sessions.bind(quest_id, normalized)
2941
+ self.quest_service.unbind_source(other_id, binding_conversation_id)
2942
+ self.sessions.unbind(other_id, binding_conversation_id)
2943
+ channel.bind_conversation(binding_conversation_id, quest_id)
2944
+ self.sessions.bind(quest_id, binding_conversation_id)
2724
2945
  self.quest_service.bind_source(quest_id, "local:default")
2725
- self.quest_service.bind_source(quest_id, normalized)
2946
+ self.quest_service.bind_source(quest_id, binding_conversation_id)
2726
2947
  if clear_scope == "all_external":
2727
- removed = self._unbind_external_bindings(quest_id, preserve={normalized})
2948
+ removed = self._unbind_external_bindings(quest_id, preserve={binding_conversation_id})
2728
2949
  elif clear_scope == "connector":
2729
- removed = self._unbind_quest_connector_bindings(quest_id, connector_name, preserve={normalized})
2950
+ removed = self._unbind_quest_connector_bindings(quest_id, connector_name, preserve={binding_conversation_id})
2730
2951
  else:
2731
2952
  removed = []
2732
2953
  snapshot = self.quest_service.snapshot(quest_id)
@@ -2743,7 +2964,7 @@ class DaemonApp:
2743
2964
  "ok": True,
2744
2965
  "quest_id": quest_id,
2745
2966
  "connector": connector_name,
2746
- "conversation_id": normalized,
2967
+ "conversation_id": binding_conversation_id,
2747
2968
  "snapshot": snapshot,
2748
2969
  "removed_conversations": removed,
2749
2970
  "conflicts_resolved": [item.get("quest_id") for item in conflicts if item.get("quest_id")],
@@ -2790,7 +3011,7 @@ class DaemonApp:
2790
3011
  "message": f"Conversation `{normalized}` does not belong to connector `{normalized_connector}`.",
2791
3012
  }
2792
3013
 
2793
- return self._apply_conversation_binding(quest_id, normalized, force=force, clear_scope="connector")
3014
+ return self._apply_conversation_binding(quest_id, normalized, force=force, clear_scope="all_external")
2794
3015
 
2795
3016
  def update_quest_bindings(
2796
3017
  self,
@@ -2803,6 +3024,12 @@ class DaemonApp:
2803
3024
  if not quest_root.joinpath("quest.yaml").exists():
2804
3025
  return 404, {"ok": False, "message": f"Unknown quest `{quest_id}`."}
2805
3026
  normalized_bindings = self._normalize_requested_connector_bindings(requested_bindings)
3027
+ if len(normalized_bindings) > 1:
3028
+ return 400, {
3029
+ "ok": False,
3030
+ "message": "A quest may bind at most one external connector target.",
3031
+ "quest_id": quest_id,
3032
+ }
2806
3033
  conflicts = self.preview_connector_binding_conflicts(normalized_bindings, quest_id=quest_id)
2807
3034
  if conflicts and not force:
2808
3035
  return 409, {
@@ -2959,12 +3186,22 @@ class DaemonApp:
2959
3186
  channel = self._channel_with_bindings(connector_name)
2960
3187
  connector_label = self._connector_label(connector_name)
2961
3188
  conversation_id = str(message.get("conversation_id") or "")
3189
+ binding_conversation_id = self._logical_connector_binding_conversation(connector_name, conversation_id)
2962
3190
  text = str(message.get("text") or "").strip()
2963
3191
  command_prefix = channel.command_prefix()
2964
3192
  quest_id = channel.resolve_bound_quest(conversation_id)
2965
-
3193
+ if quest_id is None and str(connector_name or "").strip().lower() == "lingzhu":
3194
+ quest_id = self._resolve_lingzhu_bound_quest(conversation_id)
3195
+ command_name = ""
3196
+ args: list[str] = []
2966
3197
  if text.startswith(command_prefix):
2967
3198
  command_name, args = self._parse_prefixed_command(text, command_prefix)
3199
+ elif str(connector_name or "").strip().lower() == "lingzhu":
3200
+ parsed_lingzhu_command = self._parse_lingzhu_short_command(text)
3201
+ if parsed_lingzhu_command is not None:
3202
+ command_name, args = parsed_lingzhu_command
3203
+
3204
+ if command_name:
2968
3205
  if command_name == "help":
2969
3206
  return channel.send(
2970
3207
  {
@@ -3001,7 +3238,7 @@ class DaemonApp:
3001
3238
  announce_connector_binding=True,
3002
3239
  exclude_conversation_id=conversation_id,
3003
3240
  )
3004
- self.update_quest_binding(created["quest_id"], conversation_id, force=True)
3241
+ self.update_quest_binding(created["quest_id"], binding_conversation_id, force=True)
3005
3242
  self.submit_user_message(
3006
3243
  created["quest_id"],
3007
3244
  text=goal_text,
@@ -3051,7 +3288,23 @@ class DaemonApp:
3051
3288
  ),
3052
3289
  }
3053
3290
  )
3054
- self.update_quest_binding(target_quest, conversation_id, force=True)
3291
+ previous_external = self._quest_external_binding(target_quest)
3292
+ binding_result = self.update_quest_binding(target_quest, binding_conversation_id, force=True)
3293
+ if isinstance(binding_result, tuple):
3294
+ _status, payload = binding_result
3295
+ return channel.send(
3296
+ {
3297
+ "conversation_id": conversation_id,
3298
+ "kind": "ack",
3299
+ "message": str(payload.get("message") or "Unable to switch connector binding."),
3300
+ }
3301
+ )
3302
+ transition = self._binding_transition_summary(
3303
+ quest_id=target_quest,
3304
+ previous_conversation_id=previous_external,
3305
+ current_conversation_id=self._quest_external_binding(target_quest),
3306
+ )
3307
+ self._announce_binding_transition(transition, notify_new=False, notify_old=True)
3055
3308
  return channel.send(
3056
3309
  {
3057
3310
  "conversation_id": conversation_id,
@@ -3139,9 +3392,22 @@ class DaemonApp:
3139
3392
  }
3140
3393
  )
3141
3394
 
3142
- if quest_id is None and command_name and command_name not in {"help", "projects", "quests", "list", "new", "use", "delete"}:
3395
+ if quest_id is None and command_name not in {"help", "projects", "quests", "list", "new", "use", "delete"}:
3143
3396
  auto_bound = self._maybe_auto_bind_connector_conversation(connector_name, conversation_id)
3144
3397
  if auto_bound is not None:
3398
+ if bool(auto_bound.get("blocked")):
3399
+ return channel.send(
3400
+ {
3401
+ "conversation_id": conversation_id,
3402
+ "kind": "ack",
3403
+ "message": self._connector_switch_required_message(
3404
+ connector_name=connector_name,
3405
+ quest_id=str(auto_bound.get("quest_id") or "").strip(),
3406
+ current_conversation_id=str(auto_bound.get("current_conversation_id") or "").strip(),
3407
+ requested_conversation_id=str(auto_bound.get("requested_conversation_id") or "").strip(),
3408
+ ),
3409
+ }
3410
+ )
3145
3411
  quest_id = str(auto_bound.get("quest_id") or "").strip() or None
3146
3412
 
3147
3413
  if command_name in {"stop", "resume"}:
@@ -3172,7 +3438,7 @@ class DaemonApp:
3172
3438
  target_quest_id = str(target_quest_id or "").strip()
3173
3439
  bound_quest_id = str(channel.resolve_bound_quest(conversation_id) or "").strip() or None
3174
3440
  self.sessions.bind(target_quest_id, conversation_id)
3175
- self.quest_service.bind_source(target_quest_id, conversation_id)
3441
+ self.quest_service.bind_source(target_quest_id, binding_conversation_id)
3176
3442
  result = self.control_quest(
3177
3443
  target_quest_id,
3178
3444
  action=command_name,
@@ -3197,7 +3463,7 @@ class DaemonApp:
3197
3463
  )
3198
3464
 
3199
3465
  self.sessions.bind(quest_id, conversation_id)
3200
- self.quest_service.bind_source(quest_id, conversation_id)
3466
+ self.quest_service.bind_source(quest_id, binding_conversation_id)
3201
3467
  if command_name == "status":
3202
3468
  snapshot = self.quest_service.snapshot(quest_id)
3203
3469
  return channel.send(
@@ -3370,6 +3636,19 @@ class DaemonApp:
3370
3636
  if quest_id is None:
3371
3637
  auto_bound = self._maybe_auto_bind_connector_conversation(connector_name, conversation_id)
3372
3638
  if auto_bound is not None:
3639
+ if bool(auto_bound.get("blocked")):
3640
+ return channel.send(
3641
+ {
3642
+ "conversation_id": conversation_id,
3643
+ "kind": "ack",
3644
+ "message": self._connector_switch_required_message(
3645
+ connector_name=connector_name,
3646
+ quest_id=str(auto_bound.get("quest_id") or "").strip(),
3647
+ current_conversation_id=str(auto_bound.get("current_conversation_id") or "").strip(),
3648
+ requested_conversation_id=str(auto_bound.get("requested_conversation_id") or "").strip(),
3649
+ ),
3650
+ }
3651
+ )
3373
3652
  quest_id = str(auto_bound.get("quest_id") or "").strip() or None
3374
3653
 
3375
3654
  if quest_id is None:
@@ -3382,6 +3661,7 @@ class DaemonApp:
3382
3661
  )
3383
3662
 
3384
3663
  self.sessions.bind(quest_id, conversation_id)
3664
+ self.quest_service.bind_source(quest_id, binding_conversation_id)
3385
3665
  materialized_attachments = self._materialize_connector_attachments(
3386
3666
  quest_id=quest_id,
3387
3667
  connector_name=connector_name,
@@ -3500,13 +3780,35 @@ class DaemonApp:
3500
3780
  name = str(resolved.get("name") or "").strip()
3501
3781
  content_type = str(resolved.get("content_type") or "").strip()
3502
3782
  url = str(resolved.get("url") or "").strip()
3783
+ path = str(resolved.get("path") or "").strip()
3503
3784
  target_path = batch_root / self._connector_attachment_filename(index=index, name=name, content_type=content_type)
3504
3785
  resolved["manifest_path"] = str(batch_root / "manifest.json")
3505
3786
  resolved["batch_path"] = str(batch_root)
3506
- if not url:
3507
- resolved["materialized"] = False
3508
- resolved["download_error"] = "missing_download_url"
3509
- return resolved
3787
+ if path:
3788
+ try:
3789
+ source_path = Path(path).expanduser()
3790
+ if not source_path.is_absolute():
3791
+ source_path = (quest_root / source_path).resolve()
3792
+ else:
3793
+ source_path = source_path.resolve()
3794
+ if not source_path.exists():
3795
+ raise FileNotFoundError(f"attachment local path does not exist: {source_path}")
3796
+ size_bytes = self._copy_connector_attachment(
3797
+ source_path=source_path,
3798
+ target_path=target_path,
3799
+ )
3800
+ resolved["path"] = str(target_path)
3801
+ resolved["source_path"] = str(source_path)
3802
+ resolved["quest_relative_path"] = str(target_path.relative_to(quest_root))
3803
+ resolved["size_bytes"] = int(size_bytes)
3804
+ resolved["materialized"] = True
3805
+ resolved["downloaded_at"] = utc_now()
3806
+ return resolved
3807
+ except Exception as exc:
3808
+ if not url:
3809
+ resolved["materialized"] = False
3810
+ resolved["download_error"] = str(exc)
3811
+ return resolved
3510
3812
  try:
3511
3813
  size_bytes = self._download_connector_attachment(
3512
3814
  connector_name=connector_name,
@@ -3526,6 +3828,28 @@ class DaemonApp:
3526
3828
  resolved["download_error"] = str(exc)
3527
3829
  return resolved
3528
3830
 
3831
+ def _copy_connector_attachment(
3832
+ self,
3833
+ *,
3834
+ source_path: Path,
3835
+ target_path: Path,
3836
+ ) -> int:
3837
+ ensure_dir(target_path.parent)
3838
+ total = 0
3839
+ with source_path.open("rb") as source_handle:
3840
+ with target_path.open("wb") as target_handle:
3841
+ while True:
3842
+ chunk = source_handle.read(65536)
3843
+ if not chunk:
3844
+ break
3845
+ total += len(chunk)
3846
+ if total > self._MAX_INBOUND_ATTACHMENT_BYTES:
3847
+ raise ValueError(
3848
+ f"attachment exceeds max inbound size limit ({self._MAX_INBOUND_ATTACHMENT_BYTES} bytes)"
3849
+ )
3850
+ target_handle.write(chunk)
3851
+ return total
3852
+
3529
3853
  def _download_connector_attachment(
3530
3854
  self,
3531
3855
  *,
@@ -3611,37 +3935,37 @@ class DaemonApp:
3611
3935
  if not candidates:
3612
3936
  return []
3613
3937
 
3614
- bound_conversations: list[str] = []
3615
- seen_identity_keys: set[str] = set()
3616
- for connector_name, conversation_id in candidates:
3617
- original_identity = conversation_identity_key(conversation_id)
3618
- if original_identity in seen_identity_keys:
3619
- continue
3620
- result = self._apply_conversation_binding(quest_id, conversation_id, force=True, clear_scope="none")
3621
- if isinstance(result, tuple):
3622
- continue
3623
- bound_conversation = str(result.get("conversation_id") or "").strip() or conversation_id
3624
- identity_key = conversation_identity_key(bound_conversation)
3625
- if identity_key in seen_identity_keys:
3626
- continue
3627
- seen_identity_keys.add(identity_key)
3628
- bound_conversations.append(bound_conversation)
3629
- if announce:
3630
- channel = self._channel_with_bindings(connector_name)
3631
- channel.send(
3632
- {
3633
- "conversation_id": bound_conversation,
3634
- "quest_id": quest_id,
3635
- "kind": "ack",
3636
- "message": self._quest_created_connector_message(
3637
- connector_name,
3638
- quest_id=quest_id,
3639
- goal=goal,
3640
- previous_quest_id=str(result.get("previous_quest_id") or "").strip() or None,
3641
- ),
3642
- }
3643
- )
3644
- return bound_conversations
3938
+ preferred_conversation_id = str(self.connector_availability_summary().get("preferred_conversation_id") or "").strip()
3939
+ selected: tuple[str, str] | None = None
3940
+ if preferred_conversation_id:
3941
+ for item in candidates:
3942
+ if conversation_identity_key(item[1]) == conversation_identity_key(preferred_conversation_id):
3943
+ selected = item
3944
+ break
3945
+ if selected is None:
3946
+ selected = candidates[0]
3947
+
3948
+ connector_name, conversation_id = selected
3949
+ result = self._apply_conversation_binding(quest_id, conversation_id, force=True, clear_scope="none")
3950
+ if isinstance(result, tuple):
3951
+ return []
3952
+ bound_conversation = str(result.get("conversation_id") or "").strip() or conversation_id
3953
+ if announce:
3954
+ channel = self._channel_with_bindings(connector_name)
3955
+ channel.send(
3956
+ {
3957
+ "conversation_id": bound_conversation,
3958
+ "quest_id": quest_id,
3959
+ "kind": "ack",
3960
+ "message": self._quest_created_connector_message(
3961
+ connector_name,
3962
+ quest_id=quest_id,
3963
+ goal=goal,
3964
+ previous_quest_id=str(result.get("previous_quest_id") or "").strip() or None,
3965
+ ),
3966
+ }
3967
+ )
3968
+ return [bound_conversation]
3645
3969
 
3646
3970
  def _latest_connector_conversation_id(self, connector_name: str) -> str:
3647
3971
  candidates = self._latest_connector_conversation_ids(connector_name)
@@ -3801,12 +4125,24 @@ class DaemonApp:
3801
4125
  latest_quest_id = self._latest_quest_id()
3802
4126
  if latest_quest_id is None:
3803
4127
  return None
3804
- result = self.update_quest_binding(latest_quest_id, conversation_id, force=True)
4128
+ normalized_conversation_id = self._logical_connector_binding_conversation(connector_name, conversation_id)
4129
+ current_external = self._quest_external_binding(latest_quest_id)
4130
+ if current_external and conversation_identity_key(current_external) != conversation_identity_key(normalized_conversation_id):
4131
+ return {
4132
+ "ok": False,
4133
+ "blocked": True,
4134
+ "quest_id": latest_quest_id,
4135
+ "current_conversation_id": current_external,
4136
+ "requested_conversation_id": normalized_conversation_id,
4137
+ }
4138
+ result = self.update_quest_binding(latest_quest_id, normalized_conversation_id, force=True)
3805
4139
  if isinstance(result, tuple):
3806
4140
  return None
3807
4141
  return result
3808
4142
 
3809
4143
  def _connector_home_help(self, connector_name: str, *, message: dict) -> str:
4144
+ if str(connector_name or "").strip().lower() == "lingzhu":
4145
+ return self._with_qq_main_chat_notice(message, self._lingzhu_unbound_help_text())
3810
4146
  quests = self.quest_service.list_quests()
3811
4147
  latest = str(quests[0]["quest_id"]) if quests else "none"
3812
4148
  body = self._polite_copy(
@@ -3839,6 +4175,241 @@ class DaemonApp:
3839
4175
  )
3840
4176
  return self._with_qq_main_chat_notice(message, body)
3841
4177
 
4178
+ def _quest_external_binding(self, quest_id: str | None) -> str | None:
4179
+ normalized_quest_id = str(quest_id or "").strip()
4180
+ if not normalized_quest_id:
4181
+ return None
4182
+ for source in self.quest_service.binding_sources(normalized_quest_id):
4183
+ parsed = parse_conversation_id(source)
4184
+ if parsed is None:
4185
+ continue
4186
+ if str(parsed.get("connector") or "").strip().lower() == "local":
4187
+ continue
4188
+ return normalize_conversation_id(source)
4189
+ return None
4190
+
4191
+ def _lingzhu_passive_conversation_id(self) -> str | None:
4192
+ lingzhu_config = self.connectors_config.get("lingzhu")
4193
+ resolved = dict(lingzhu_config) if isinstance(lingzhu_config, dict) else {}
4194
+ auth_ak = self.config_manager._secret(resolved, "auth_ak", "auth_ak_env")
4195
+ if not auth_ak:
4196
+ return None
4197
+ return lingzhu_passive_conversation_id(resolved)
4198
+
4199
+ def _logical_connector_binding_conversation(self, connector_name: str, conversation_id: str | None) -> str:
4200
+ normalized_connector = str(connector_name or "").strip().lower()
4201
+ if normalized_connector == "lingzhu":
4202
+ passive_conversation_id = self._lingzhu_passive_conversation_id()
4203
+ if passive_conversation_id:
4204
+ return passive_conversation_id
4205
+ return normalize_conversation_id(conversation_id)
4206
+
4207
+ def _remove_connector_sources_from_quest(self, quest_id: str, connector_name: str) -> None:
4208
+ normalized_connector = str(connector_name or "").strip().lower()
4209
+ if not normalized_connector:
4210
+ return
4211
+ current_sources = self.quest_service.binding_sources(quest_id)
4212
+ filtered_sources = []
4213
+ for source in current_sources:
4214
+ parsed = parse_conversation_id(source)
4215
+ if parsed is not None and str(parsed.get("connector") or "").strip().lower() == normalized_connector:
4216
+ continue
4217
+ filtered_sources.append(source)
4218
+ self.quest_service.set_binding_sources(quest_id, filtered_sources or ["local:default"])
4219
+
4220
+ def _canonicalize_lingzhu_binding_state(self) -> None:
4221
+ passive_conversation_id = self._lingzhu_passive_conversation_id()
4222
+ if not passive_conversation_id:
4223
+ return
4224
+ try:
4225
+ channel = self._channel_with_bindings("lingzhu")
4226
+ except Exception:
4227
+ return
4228
+ bindings = [dict(item) for item in channel.list_bindings() if isinstance(item, dict)]
4229
+ selected_quest_id: str | None = None
4230
+ selected_updated_at = ""
4231
+ quests_with_lingzhu_sources: set[str] = set()
4232
+
4233
+ for item in bindings:
4234
+ quest_id = str(item.get("quest_id") or "").strip()
4235
+ updated_at = str(item.get("updated_at") or "").strip()
4236
+ if quest_id and (updated_at, quest_id) >= (selected_updated_at, str(selected_quest_id or "")):
4237
+ selected_quest_id = quest_id
4238
+ selected_updated_at = updated_at
4239
+
4240
+ for quest in self.quest_service.list_quests():
4241
+ quest_id = str(quest.get("quest_id") or "").strip()
4242
+ if not quest_id:
4243
+ continue
4244
+ sources = self.quest_service.binding_sources(quest_id)
4245
+ if any(
4246
+ (
4247
+ parsed := parse_conversation_id(source)
4248
+ ) is not None and str(parsed.get("connector") or "").strip().lower() == "lingzhu"
4249
+ for source in sources
4250
+ ):
4251
+ quests_with_lingzhu_sources.add(quest_id)
4252
+ if not selected_quest_id:
4253
+ selected_quest_id = quest_id
4254
+
4255
+ for item in bindings:
4256
+ conversation_id = str(item.get("conversation_id") or "").strip()
4257
+ quest_id = str(item.get("quest_id") or "").strip() or None
4258
+ if not conversation_id:
4259
+ continue
4260
+ channel.unbind_conversation(conversation_id, quest_id=quest_id)
4261
+ if quest_id:
4262
+ self.sessions.unbind(quest_id, conversation_id)
4263
+
4264
+ for quest_id in quests_with_lingzhu_sources:
4265
+ self._remove_connector_sources_from_quest(quest_id, "lingzhu")
4266
+
4267
+ if selected_quest_id:
4268
+ channel.bind_conversation(passive_conversation_id, selected_quest_id)
4269
+ self.quest_service.bind_source(selected_quest_id, "local:default")
4270
+ self.quest_service.bind_source(selected_quest_id, passive_conversation_id)
4271
+
4272
+ def _resolve_lingzhu_bound_quest(self, conversation_id: str) -> str | None:
4273
+ normalized_conversation_id = normalize_conversation_id(conversation_id)
4274
+ channel = self._channel_with_bindings("lingzhu")
4275
+ known_quest_id = str(channel.resolve_bound_quest(normalized_conversation_id) or "").strip() or None
4276
+ if known_quest_id:
4277
+ return known_quest_id
4278
+ passive_conversation_id = self._lingzhu_passive_conversation_id()
4279
+ passive_quest_id = str(channel.resolve_bound_quest(passive_conversation_id) or "").strip() or None
4280
+ if not passive_quest_id:
4281
+ return None
4282
+ return passive_quest_id
4283
+
4284
+ def _connector_target_label(self, conversation_id: str | None) -> str:
4285
+ normalized = normalize_conversation_id(conversation_id)
4286
+ parsed = parse_conversation_id(normalized)
4287
+ if parsed is None:
4288
+ return str(conversation_id or "unknown").strip() or "unknown"
4289
+ connector_label = self._connector_label(str(parsed.get("connector") or "").strip())
4290
+ profile_id = str(parsed.get("profile_id") or "").strip()
4291
+ if lingzhu_is_passive_conversation_id(normalized):
4292
+ agent_id = str(parsed.get("chat_id_raw") or parsed.get("chat_id") or "").strip() or "main"
4293
+ return f"{connector_label} · passive · {agent_id}"
4294
+ chat_id = str(parsed.get("chat_id_raw") or parsed.get("chat_id") or normalized).strip()
4295
+ if profile_id:
4296
+ return f"{connector_label} · {profile_id} · {chat_id}"
4297
+ return f"{connector_label} · {chat_id}"
4298
+
4299
+ def _binding_transition_summary(
4300
+ self,
4301
+ *,
4302
+ quest_id: str,
4303
+ previous_conversation_id: str | None,
4304
+ current_conversation_id: str | None,
4305
+ ) -> dict[str, Any]:
4306
+ previous = normalize_conversation_id(previous_conversation_id)
4307
+ current = normalize_conversation_id(current_conversation_id)
4308
+ if conversation_identity_key(previous) == conversation_identity_key(current):
4309
+ mode = "unchanged"
4310
+ elif previous and current:
4311
+ mode = "switch"
4312
+ elif current:
4313
+ mode = "bind"
4314
+ elif previous:
4315
+ mode = "disconnect"
4316
+ else:
4317
+ mode = "unchanged"
4318
+ return {
4319
+ "quest_id": quest_id,
4320
+ "mode": mode,
4321
+ "previous_conversation_id": previous or None,
4322
+ "previous_label": self._connector_target_label(previous) if previous else None,
4323
+ "current_conversation_id": current or None,
4324
+ "current_label": self._connector_target_label(current) if current else None,
4325
+ "changed": mode != "unchanged",
4326
+ }
4327
+
4328
+ def _announce_binding_transition(
4329
+ self,
4330
+ summary: dict[str, Any] | None,
4331
+ *,
4332
+ notify_new: bool,
4333
+ notify_old: bool,
4334
+ ) -> None:
4335
+ if not isinstance(summary, dict) or not bool(summary.get("changed")):
4336
+ return
4337
+ quest_id = str(summary.get("quest_id") or "").strip()
4338
+ previous_conversation_id = str(summary.get("previous_conversation_id") or "").strip() or None
4339
+ current_conversation_id = str(summary.get("current_conversation_id") or "").strip() or None
4340
+ previous_label = str(summary.get("previous_label") or "").strip() or None
4341
+ current_label = str(summary.get("current_label") or "").strip() or None
4342
+ mode = str(summary.get("mode") or "").strip()
4343
+
4344
+ if notify_old and previous_conversation_id and conversation_identity_key(previous_conversation_id) != conversation_identity_key(current_conversation_id):
4345
+ old_connector = str((parse_conversation_id(previous_conversation_id) or {}).get("connector") or "").strip().lower()
4346
+ if old_connector and old_connector in self.channels:
4347
+ channel = self._channel_with_bindings(old_connector)
4348
+ if mode == "disconnect":
4349
+ message = self._polite_copy(
4350
+ zh=f"当前已退出 Quest `{quest_id}`,项目已切换为仅本地。",
4351
+ en=f"This conversation is no longer bound to Quest `{quest_id}`. The project is now local only.",
4352
+ )
4353
+ else:
4354
+ message = self._polite_copy(
4355
+ zh=f"当前已退出 Quest `{quest_id}`,后续请在 {current_label} 查看进展。",
4356
+ en=f"This conversation is no longer bound to Quest `{quest_id}`. Continue from {current_label}.",
4357
+ )
4358
+ channel.send(
4359
+ {
4360
+ "conversation_id": previous_conversation_id,
4361
+ "quest_id": quest_id,
4362
+ "kind": "binding_notice",
4363
+ "message": message,
4364
+ }
4365
+ )
4366
+
4367
+ if notify_new and current_conversation_id:
4368
+ new_connector = str((parse_conversation_id(current_conversation_id) or {}).get("connector") or "").strip().lower()
4369
+ if new_connector and new_connector in self.channels:
4370
+ channel = self._channel_with_bindings(new_connector)
4371
+ if mode == "bind":
4372
+ message = self._polite_copy(
4373
+ zh=f"当前已绑定 Quest `{quest_id}`。",
4374
+ en=f"This conversation is now bound to Quest `{quest_id}`.",
4375
+ )
4376
+ elif mode == "switch":
4377
+ message = self._polite_copy(
4378
+ zh=f"当前已绑定 Quest `{quest_id}`,并已从 {previous_label} 切换到当前会话。",
4379
+ en=f"This conversation is now bound to Quest `{quest_id}`, replacing {previous_label}.",
4380
+ )
4381
+ else:
4382
+ message = ""
4383
+ if message:
4384
+ channel.send(
4385
+ {
4386
+ "conversation_id": current_conversation_id,
4387
+ "quest_id": quest_id,
4388
+ "kind": "binding_notice",
4389
+ "message": message,
4390
+ }
4391
+ )
4392
+
4393
+ def _connector_switch_required_message(
4394
+ self,
4395
+ *,
4396
+ connector_name: str,
4397
+ quest_id: str,
4398
+ current_conversation_id: str,
4399
+ requested_conversation_id: str,
4400
+ ) -> str:
4401
+ switch_command = f"绑定{quest_id}" if str(connector_name or "").strip().lower() == "lingzhu" else f"/use {quest_id}"
4402
+ return self._polite_copy(
4403
+ zh=(
4404
+ f"当前 Quest `{quest_id}` 已绑定 {self._connector_target_label(current_conversation_id)}。\n"
4405
+ f"如需切换到 {self._connector_target_label(requested_conversation_id)},请发送 `{switch_command}`,或在项目设置里保存切换。"
4406
+ ),
4407
+ en=(
4408
+ f"Quest `{quest_id}` is already bound to {self._connector_target_label(current_conversation_id)}.\n"
4409
+ f"To switch to {self._connector_target_label(requested_conversation_id)}, send `{switch_command}` or save the change from project settings."
4410
+ ),
4411
+ )
4412
+
3842
4413
  def _unbind_external_bindings(self, quest_id: str, *, preserve: set[str] | None = None) -> list[str]:
3843
4414
  preserve_keys = {conversation_identity_key(item) for item in (preserve or set()) if item}
3844
4415
  removed: list[str] = []
@@ -3924,6 +4495,46 @@ class DaemonApp:
3924
4495
  parts = stripped.split()
3925
4496
  return parts[0].lower(), parts[1:]
3926
4497
 
4498
+ @staticmethod
4499
+ def _parse_lingzhu_short_command(text: str) -> tuple[str, list[str]] | None:
4500
+ normalized = re.sub(r"\s+", " ", str(text or "").strip())
4501
+ if not normalized or normalized.startswith("/"):
4502
+ return None
4503
+ direct = _LINGZHU_SHORT_COMMAND_DIRECT_MAP.get(normalized)
4504
+ if direct:
4505
+ return direct, []
4506
+ for prefix, command_name in _LINGZHU_SHORT_COMMAND_PREFIX_MAP.items():
4507
+ if not normalized.startswith(prefix):
4508
+ continue
4509
+ remainder = normalized[len(prefix) :].strip().lstrip("::,,。.;;!!?? ")
4510
+ if command_name == "new":
4511
+ return command_name, [remainder] if remainder else []
4512
+ if command_name == "delete":
4513
+ matched = re.match(r"^(?P<target>\S+)?(?:\s+(?P<confirm>\S+))?$", remainder)
4514
+ target = str((matched.group("target") if matched else "") or "").strip()
4515
+ confirm = str((matched.group("confirm") if matched else "") or "").strip()
4516
+ args: list[str] = []
4517
+ if target:
4518
+ args.append("latest" if target in _LINGZHU_SHORT_LATEST_ALIASES else target)
4519
+ if confirm in _LINGZHU_DELETE_CONFIRM_ALIASES:
4520
+ args.append("--yes")
4521
+ return command_name, args
4522
+ if remainder:
4523
+ return command_name, ["latest" if remainder in _LINGZHU_SHORT_LATEST_ALIASES else remainder]
4524
+ return command_name, []
4525
+ return None
4526
+
4527
+ def _lingzhu_unbound_help_text(self) -> str:
4528
+ latest = str(self._latest_quest_id() or "none")
4529
+ return (
4530
+ "当前还没绑定 Quest。\n"
4531
+ "可直接说:帮助、列表、绑定025、绑定最新、新建 复现一个 baseline。\n"
4532
+ f"当前最新 Quest:`{latest}`。\n"
4533
+ "绑定后再说:我现在的任务是 ……\n"
4534
+ "查看进展可说:继续 或 汇报。\n"
4535
+ "快捷指令:状态、总结、暂停、恢复、删除025。"
4536
+ )
4537
+
3927
4538
  def _maybe_bind_qq_main_chat(self, message: dict) -> dict | None:
3928
4539
  chat_type = str(message.get("chat_type") or "").strip().lower()
3929
4540
  if chat_type != "direct":
@@ -4053,6 +4664,20 @@ class DaemonApp:
4053
4664
  )
4054
4665
  if gateway.start():
4055
4666
  self._qq_gateways[profile_id] = gateway
4667
+ weixin_config = self.connectors_config.get("weixin", {})
4668
+ if self._is_connector_system_enabled("weixin") and isinstance(weixin_config, dict) and self._weixin_ilink is None:
4669
+ weixin = WeixinIlinkService(
4670
+ home=self.home,
4671
+ config=weixin_config,
4672
+ on_event=lambda event: self.handle_connector_inbound("weixin", event),
4673
+ log=lambda level, message: self.logger.log(
4674
+ level,
4675
+ "connector.weixin_ilink",
4676
+ message=message,
4677
+ ),
4678
+ )
4679
+ if weixin.start():
4680
+ self._weixin_ilink = weixin
4056
4681
  if self._is_connector_system_enabled("telegram") and not self._telegram_polling:
4057
4682
  for profile_id, profile_label, profile_config in self._profiled_connector_configs("telegram"):
4058
4683
  polling = TelegramPollingService(
@@ -4149,6 +4774,10 @@ class DaemonApp:
4149
4774
  self._qq_gateways = {}
4150
4775
  for gateway in gateways:
4151
4776
  gateway.stop()
4777
+ weixin = self._weixin_ilink
4778
+ self._weixin_ilink = None
4779
+ if weixin is not None:
4780
+ weixin.stop()
4152
4781
  polling = list(self._telegram_polling.values())
4153
4782
  self._telegram_polling = {}
4154
4783
  for item in polling:
@@ -4263,6 +4892,435 @@ class DaemonApp:
4263
4892
  accept = str(headers.get("Accept") or headers.get("accept") or "").lower()
4264
4893
  return stream_value in {"1", "true", "yes", "stream"} or "text/event-stream" in accept
4265
4894
 
4895
+ def lingzhu_health_payload(self) -> dict[str, Any]:
4896
+ config = self.connectors_config.get("lingzhu")
4897
+ resolved = dict(config) if isinstance(config, dict) else {}
4898
+ return lingzhu_health_payload(resolved, chat_completions_enabled=True)
4899
+
4900
+ def _lingzhu_state_path(self) -> Path:
4901
+ return self.home / "logs" / "connectors" / "lingzhu" / "metis_state.json"
4902
+
4903
+ def _read_lingzhu_state(self) -> dict[str, Any]:
4904
+ payload = read_json(self._lingzhu_state_path(), {"delivered_counts": {}})
4905
+ if not isinstance(payload, dict):
4906
+ payload = {}
4907
+ delivered_counts = payload.get("delivered_counts")
4908
+ if not isinstance(delivered_counts, dict):
4909
+ delivered_counts = {}
4910
+ return {"delivered_counts": delivered_counts}
4911
+
4912
+ def _write_lingzhu_state(self, payload: dict[str, Any]) -> None:
4913
+ path = self._lingzhu_state_path()
4914
+ ensure_dir(path.parent)
4915
+ write_json(path, payload)
4916
+
4917
+ def _lingzhu_delivered_count(self, conversation_id: str) -> int:
4918
+ delivered_counts = self._read_lingzhu_state().get("delivered_counts") or {}
4919
+ raw_value = delivered_counts.get(conversation_identity_key(conversation_id))
4920
+ try:
4921
+ return max(0, int(raw_value))
4922
+ except (TypeError, ValueError):
4923
+ return 0
4924
+
4925
+ def _set_lingzhu_delivered_count(self, conversation_id: str, delivered_count: int) -> None:
4926
+ state = self._read_lingzhu_state()
4927
+ counts = dict(state.get("delivered_counts") or {})
4928
+ counts[conversation_identity_key(conversation_id)] = max(0, int(delivered_count))
4929
+ state["delivered_counts"] = counts
4930
+ self._write_lingzhu_state(state)
4931
+
4932
+ def _lingzhu_outbox_records(self, conversation_id: str) -> list[dict[str, Any]]:
4933
+ outbox_path = self.home / "logs" / "connectors" / "lingzhu" / "outbox.jsonl"
4934
+ target_key = conversation_identity_key(conversation_id)
4935
+ items: list[dict[str, Any]] = []
4936
+ for record in read_jsonl(outbox_path):
4937
+ if not isinstance(record, dict):
4938
+ continue
4939
+ current_conversation_id = str(record.get("conversation_id") or "").strip()
4940
+ if not current_conversation_id:
4941
+ continue
4942
+ if conversation_identity_key(current_conversation_id) != target_key:
4943
+ continue
4944
+ text = str(record.get("text") or "").strip()
4945
+ if not text:
4946
+ continue
4947
+ items.append(dict(record))
4948
+ return items
4949
+
4950
+ def _lingzhu_pending_outbox_records(
4951
+ self,
4952
+ conversation_id: str,
4953
+ *,
4954
+ delivered_count: int | None = None,
4955
+ ) -> tuple[list[dict[str, Any]], int]:
4956
+ records = self._lingzhu_outbox_records(conversation_id)
4957
+ baseline = self._lingzhu_delivered_count(conversation_id) if delivered_count is None else delivered_count
4958
+ applied_baseline = max(0, min(int(baseline), len(records)))
4959
+ return records[applied_baseline:], len(records)
4960
+
4961
+ @staticmethod
4962
+ def _lingzhu_wait_timeout_seconds(config: dict[str, Any]) -> float:
4963
+ try:
4964
+ timeout_ms = int(config.get("request_timeout_ms") or 60000)
4965
+ except (TypeError, ValueError):
4966
+ timeout_ms = 60000
4967
+ timeout_ms = max(15000, min(timeout_ms, 120000))
4968
+ return timeout_ms / 1000.0
4969
+
4970
+ def _lingzhu_wait_for_outbox_records(
4971
+ self,
4972
+ conversation_id: str,
4973
+ *,
4974
+ delivered_count: int,
4975
+ timeout_seconds: float,
4976
+ ) -> tuple[list[dict[str, Any]], int]:
4977
+ deadline = time.monotonic() + max(0.1, timeout_seconds)
4978
+ while time.monotonic() < deadline:
4979
+ pending_records, total_count = self._lingzhu_pending_outbox_records(
4980
+ conversation_id,
4981
+ delivered_count=delivered_count,
4982
+ )
4983
+ if pending_records:
4984
+ return pending_records, total_count
4985
+ time.sleep(0.25)
4986
+ return self._lingzhu_pending_outbox_records(conversation_id, delivered_count=delivered_count)
4987
+
4988
+ def _lingzhu_emit_outbox_records(
4989
+ self,
4990
+ handler: BaseHTTPRequestHandler,
4991
+ *,
4992
+ message_id: str,
4993
+ agent_id: str,
4994
+ records: list[dict[str, Any]],
4995
+ config: dict[str, Any] | None = None,
4996
+ ) -> int:
4997
+ emitted = 0
4998
+ resolved = dict(config or {})
4999
+ default_navigation_mode = str(resolved.get("default_navigation_mode") or "0").strip() or "0"
5000
+ experimental_enabled = bool(resolved.get("enable_experimental_native_actions", False))
5001
+ for record in records:
5002
+ raw_text = str(record.get("text") or "").strip()
5003
+ detected_tool_call = None
5004
+ text = raw_text
5005
+ if raw_text:
5006
+ detected_tool_call, text = lingzhu_detect_tool_call_from_text(
5007
+ raw_text,
5008
+ default_navigation_mode=default_navigation_mode,
5009
+ experimental_enabled=experimental_enabled,
5010
+ )
5011
+ if text:
5012
+ self._write_sse_event(
5013
+ handler,
5014
+ event="message",
5015
+ data=lingzhu_sse_answer(
5016
+ message_id=message_id,
5017
+ agent_id=agent_id,
5018
+ answer_stream=text,
5019
+ is_finish=True,
5020
+ ),
5021
+ )
5022
+ emitted += 1
5023
+ emitted_tool_call = False
5024
+ for action in record.get("surface_actions") or []:
5025
+ tool_call = lingzhu_surface_action_tool_call(
5026
+ action,
5027
+ default_navigation_mode=default_navigation_mode,
5028
+ experimental_enabled=experimental_enabled,
5029
+ )
5030
+ if not tool_call:
5031
+ continue
5032
+ self._write_sse_event(
5033
+ handler,
5034
+ event="message",
5035
+ data=lingzhu_sse_tool_call(
5036
+ message_id=message_id,
5037
+ agent_id=agent_id,
5038
+ tool_call=tool_call,
5039
+ is_finish=True,
5040
+ ),
5041
+ )
5042
+ emitted += 1
5043
+ emitted_tool_call = True
5044
+ if not emitted_tool_call and detected_tool_call:
5045
+ self._write_sse_event(
5046
+ handler,
5047
+ event="message",
5048
+ data=lingzhu_sse_tool_call(
5049
+ message_id=message_id,
5050
+ agent_id=agent_id,
5051
+ tool_call=detected_tool_call,
5052
+ is_finish=True,
5053
+ ),
5054
+ )
5055
+ emitted += 1
5056
+ return emitted
5057
+
5058
+ def _lingzhu_short_status_text(self, quest_id: str | None) -> str:
5059
+ if not quest_id:
5060
+ return self._lingzhu_unbound_help_text()
5061
+ snapshot = self.quest_service.snapshot(quest_id)
5062
+ runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
5063
+ if runtime_status in {"running", "active"}:
5064
+ return "进行中"
5065
+ if runtime_status == "waiting_for_user":
5066
+ return "等你确认"
5067
+ if runtime_status in {"paused", "stopped"}:
5068
+ return "已暂停"
5069
+ if runtime_status == "completed":
5070
+ return "已完成"
5071
+ if runtime_status == "error":
5072
+ return "出错了"
5073
+ return "暂无新进展"
5074
+
5075
+ @staticmethod
5076
+ def _lingzhu_reply_payload(result: dict[str, Any]) -> tuple[str, str | None, str]:
5077
+ if not isinstance(result, dict):
5078
+ return "", None, ""
5079
+ reply = result.get("reply")
5080
+ if not isinstance(reply, dict):
5081
+ return "", None, ""
5082
+ payload = reply.get("payload")
5083
+ if not isinstance(payload, dict):
5084
+ return "", None, ""
5085
+ text = str(payload.get("text") or payload.get("message") or "").strip()
5086
+ quest_id = str(payload.get("quest_id") or "").strip() or None
5087
+ kind = str(payload.get("kind") or "").strip()
5088
+ return text, quest_id, kind
5089
+
5090
+ def stream_lingzhu_sse(
5091
+ self,
5092
+ handler: BaseHTTPRequestHandler,
5093
+ *,
5094
+ raw_body: bytes,
5095
+ headers: dict[str, str],
5096
+ ) -> None:
5097
+ config = self.connectors_config.get("lingzhu")
5098
+ resolved = dict(config) if isinstance(config, dict) else {}
5099
+ if resolved.get("enabled") is False:
5100
+ handler.send_response(503)
5101
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5102
+ handler.end_headers()
5103
+ handler.wfile.write(json.dumps({"error": "Lingzhu connector is disabled"}, ensure_ascii=False).encode("utf-8"))
5104
+ return
5105
+
5106
+ auth_ak = self.config_manager._secret(resolved, "auth_ak", "auth_ak_env")
5107
+ auth_header = headers.get("Authorization") or headers.get("authorization") or ""
5108
+ if not lingzhu_verify_auth_header(auth_header, auth_ak):
5109
+ handler.send_response(401)
5110
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5111
+ handler.end_headers()
5112
+ handler.wfile.write(json.dumps({"error": "Unauthorized"}, ensure_ascii=False).encode("utf-8"))
5113
+ return
5114
+
5115
+ try:
5116
+ body = self.handlers.parse_body(raw_body)
5117
+ except Exception:
5118
+ handler.send_response(400)
5119
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5120
+ handler.end_headers()
5121
+ handler.wfile.write(json.dumps({"error": "Invalid JSON body"}, ensure_ascii=False).encode("utf-8"))
5122
+ return
5123
+
5124
+ if not isinstance(body, dict):
5125
+ handler.send_response(400)
5126
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5127
+ handler.end_headers()
5128
+ handler.wfile.write(json.dumps({"error": "Request body must be a JSON object"}, ensure_ascii=False).encode("utf-8"))
5129
+ return
5130
+
5131
+ message_id = str(body.get("message_id") or body.get("request_id") or generate_id("lingzhu")).strip()
5132
+ agent_id = str(body.get("agent_id") or resolved.get("agent_id") or "main").strip() or "main"
5133
+ messages = body.get("message")
5134
+ if not isinstance(messages, list):
5135
+ messages = body.get("messages")
5136
+ if not isinstance(messages, list):
5137
+ text = str(body.get("text") or body.get("content") or "").strip()
5138
+ messages = [{"role": "user", "type": "text", "text": text}] if text else None
5139
+ if not isinstance(messages, list):
5140
+ handler.send_response(400)
5141
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5142
+ handler.end_headers()
5143
+ handler.wfile.write(
5144
+ json.dumps({"error": "Missing required fields: message or messages"}, ensure_ascii=False).encode("utf-8")
5145
+ )
5146
+ return
5147
+
5148
+ conversation_id = lingzhu_request_conversation_id(body)
5149
+ binding_conversation_id = self._logical_connector_binding_conversation("lingzhu", conversation_id)
5150
+ sender_id = lingzhu_request_sender_id(body)
5151
+ inbound_text = lingzhu_extract_user_text(messages) or self._polite_copy(
5152
+ zh="你好,请继续。",
5153
+ en="Hello, please continue.",
5154
+ )
5155
+ channel = self._channel_with_bindings("lingzhu")
5156
+ known_quest_id = self._resolve_lingzhu_bound_quest(conversation_id)
5157
+ delivered_count = self._lingzhu_delivered_count(conversation_id)
5158
+ task_text = lingzhu_extract_task_text(inbound_text)
5159
+ is_command = inbound_text.startswith(channel.command_prefix()) or self._parse_lingzhu_short_command(inbound_text) is not None
5160
+
5161
+ inbound_payload = {
5162
+ "conversation_id": conversation_id,
5163
+ "chat_type": "direct",
5164
+ "message_id": message_id,
5165
+ "sender_id": sender_id,
5166
+ "sender_name": sender_id,
5167
+ "user_id": sender_id,
5168
+ "direct_id": sender_id,
5169
+ "text": inbound_text,
5170
+ "message": inbound_text,
5171
+ "content": inbound_text,
5172
+ "raw_event": body,
5173
+ "metadata": body.get("metadata"),
5174
+ }
5175
+
5176
+ handler.send_response(200)
5177
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
5178
+ handler.send_header("Cache-Control", "no-cache")
5179
+ handler.send_header("Connection", "close")
5180
+ handler.send_header("X-Accel-Buffering", "no")
5181
+ handler.end_headers()
5182
+
5183
+ try:
5184
+ handler.wfile.write(b": keepalive\n\n")
5185
+ handler.wfile.flush()
5186
+
5187
+ if is_command:
5188
+ result = self.handle_connector_inbound("lingzhu", inbound_payload)
5189
+ reply_text, _, _ = self._lingzhu_reply_payload(result)
5190
+ pending_records, total_count = self._lingzhu_pending_outbox_records(
5191
+ conversation_id,
5192
+ delivered_count=delivered_count,
5193
+ )
5194
+ emitted = self._lingzhu_emit_outbox_records(
5195
+ handler,
5196
+ message_id=message_id,
5197
+ agent_id=agent_id,
5198
+ records=pending_records,
5199
+ config=resolved,
5200
+ )
5201
+ if emitted:
5202
+ self._set_lingzhu_delivered_count(conversation_id, total_count)
5203
+ else:
5204
+ answer_text = reply_text
5205
+ if not answer_text:
5206
+ if not bool(result.get("accepted", False)):
5207
+ reason = str(result.get("reason") or (result.get("normalized") or {}).get("reason") or "").strip()
5208
+ answer_text = reason or "请求未接受"
5209
+ else:
5210
+ answer_text = "已收到"
5211
+ self._write_sse_event(
5212
+ handler,
5213
+ event="message",
5214
+ data=lingzhu_sse_answer(
5215
+ message_id=message_id,
5216
+ agent_id=agent_id,
5217
+ answer_stream=answer_text,
5218
+ is_finish=True,
5219
+ ),
5220
+ )
5221
+ handler.close_connection = True
5222
+ return
5223
+
5224
+ if task_text is not None:
5225
+ target_quest_id = known_quest_id
5226
+ if target_quest_id:
5227
+ self.sessions.bind(target_quest_id, conversation_id)
5228
+ self.quest_service.bind_source(target_quest_id, binding_conversation_id)
5229
+ pending_before, total_before = self._lingzhu_pending_outbox_records(
5230
+ conversation_id,
5231
+ delivered_count=delivered_count,
5232
+ )
5233
+ emitted_before = self._lingzhu_emit_outbox_records(
5234
+ handler,
5235
+ message_id=message_id,
5236
+ agent_id=agent_id,
5237
+ records=pending_before,
5238
+ config=resolved,
5239
+ )
5240
+ if emitted_before:
5241
+ delivered_count = total_before
5242
+ self._set_lingzhu_delivered_count(conversation_id, total_before)
5243
+
5244
+ if not target_quest_id:
5245
+ self._write_sse_event(
5246
+ handler,
5247
+ event="message",
5248
+ data=lingzhu_sse_answer(
5249
+ message_id=message_id,
5250
+ agent_id=agent_id,
5251
+ answer_stream=self._lingzhu_unbound_help_text(),
5252
+ is_finish=True,
5253
+ ),
5254
+ )
5255
+ handler.close_connection = True
5256
+ return
5257
+
5258
+ self.submit_user_message(
5259
+ target_quest_id,
5260
+ text=task_text,
5261
+ source=conversation_id,
5262
+ client_message_id=message_id,
5263
+ )
5264
+ pending_after, total_after = self._lingzhu_wait_for_outbox_records(
5265
+ conversation_id,
5266
+ delivered_count=delivered_count,
5267
+ timeout_seconds=self._lingzhu_wait_timeout_seconds(resolved),
5268
+ )
5269
+ emitted_after = self._lingzhu_emit_outbox_records(
5270
+ handler,
5271
+ message_id=message_id,
5272
+ agent_id=agent_id,
5273
+ records=pending_after,
5274
+ config=resolved,
5275
+ )
5276
+ if emitted_after:
5277
+ self._set_lingzhu_delivered_count(conversation_id, total_after)
5278
+ else:
5279
+ self._write_sse_event(
5280
+ handler,
5281
+ event="message",
5282
+ data=lingzhu_sse_answer(
5283
+ message_id=message_id,
5284
+ agent_id=agent_id,
5285
+ answer_stream="已开始" if emitted_before else self._lingzhu_short_status_text(target_quest_id),
5286
+ is_finish=True,
5287
+ ),
5288
+ )
5289
+ handler.close_connection = True
5290
+ return
5291
+
5292
+ if known_quest_id:
5293
+ self.sessions.bind(known_quest_id, conversation_id)
5294
+ self.quest_service.bind_source(known_quest_id, binding_conversation_id)
5295
+
5296
+ pending_records, total_count = self._lingzhu_pending_outbox_records(
5297
+ conversation_id,
5298
+ delivered_count=delivered_count,
5299
+ )
5300
+ emitted = self._lingzhu_emit_outbox_records(
5301
+ handler,
5302
+ message_id=message_id,
5303
+ agent_id=agent_id,
5304
+ records=pending_records,
5305
+ config=resolved,
5306
+ )
5307
+ if emitted:
5308
+ self._set_lingzhu_delivered_count(conversation_id, total_count)
5309
+ else:
5310
+ self._write_sse_event(
5311
+ handler,
5312
+ event="message",
5313
+ data=lingzhu_sse_answer(
5314
+ message_id=message_id,
5315
+ agent_id=agent_id,
5316
+ answer_stream=self._lingzhu_short_status_text(known_quest_id),
5317
+ is_finish=True,
5318
+ ),
5319
+ )
5320
+ handler.close_connection = True
5321
+ except (BrokenPipeError, ConnectionResetError, TimeoutError):
5322
+ return
5323
+
4266
5324
  @staticmethod
4267
5325
  def _write_sse_event(
4268
5326
  handler: BaseHTTPRequestHandler,
@@ -4730,6 +5788,14 @@ class DaemonApp:
4730
5788
  except Exception as exc:
4731
5789
  self._write_json(500, {"ok": False, "message": str(exc)})
4732
5790
  return
5791
+ if route_name == "lingzhu_sse":
5792
+ content_length = int(self.headers.get("Content-Length", "0"))
5793
+ raw_body = self.rfile.read(content_length) if content_length else b""
5794
+ try:
5795
+ app.stream_lingzhu_sse(self, raw_body=raw_body, headers=dict(self.headers.items()))
5796
+ except Exception as exc:
5797
+ self._write_json(500, {"ok": False, "message": str(exc)})
5798
+ return
4733
5799
 
4734
5800
  content_length = int(self.headers.get("Content-Length", "0"))
4735
5801
  raw_body = self.rfile.read(content_length) if content_length else b""
@@ -4765,11 +5831,14 @@ class DaemonApp:
4765
5831
  "terminal_restore",
4766
5832
  "terminal_history",
4767
5833
  "latex_builds",
5834
+ "arxiv_list",
5835
+ "annotations_file",
5836
+ "annotations_project",
4768
5837
  }:
4769
5838
  payload = result(**params, path=self.path)
4770
5839
  elif method == "GET":
4771
5840
  payload = result(**params) if params else result()
4772
- elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action"}:
5841
+ elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create"}:
4773
5842
  payload = result(**params, body=body)
4774
5843
  elif route_name == "config_validate":
4775
5844
  payload = result(body)