@researai/deepscientist 1.5.9 → 1.5.12
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/README.md +112 -99
- package/assets/branding/connector-qq.png +0 -0
- package/assets/branding/connector-rokid.png +0 -0
- package/assets/branding/connector-weixin.png +0 -0
- package/assets/branding/projects.png +0 -0
- package/bin/ds.js +519 -63
- package/docs/assets/branding/projects.png +0 -0
- package/docs/en/00_QUICK_START.md +338 -68
- package/docs/en/01_SETTINGS_REFERENCE.md +14 -0
- package/docs/en/02_START_RESEARCH_GUIDE.md +180 -4
- package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
- package/docs/en/09_DOCTOR.md +66 -5
- package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
- package/docs/en/11_LICENSE_AND_RISK.md +256 -0
- package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +446 -0
- package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
- package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
- package/docs/en/15_CODEX_PROVIDER_SETUP.md +284 -0
- package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
- package/docs/en/README.md +83 -0
- package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
- package/docs/images/weixin/weixin-plugin-entry.png +0 -0
- package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
- package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
- package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
- package/docs/images/weixin/weixin-settings-bind.svg +57 -0
- package/docs/zh/00_QUICK_START.md +345 -72
- package/docs/zh/01_SETTINGS_REFERENCE.md +14 -0
- package/docs/zh/02_START_RESEARCH_GUIDE.md +181 -3
- package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
- package/docs/zh/09_DOCTOR.md +68 -5
- package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
- package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
- package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +442 -0
- package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
- package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
- package/docs/zh/15_CODEX_PROVIDER_SETUP.md +285 -0
- package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
- package/docs/zh/README.md +129 -0
- package/install.sh +0 -34
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/annotations.py +343 -0
- package/src/deepscientist/artifact/arxiv.py +484 -37
- package/src/deepscientist/artifact/service.py +574 -108
- package/src/deepscientist/arxiv_library.py +275 -0
- package/src/deepscientist/bash_exec/monitor.py +7 -5
- package/src/deepscientist/bash_exec/service.py +93 -21
- package/src/deepscientist/bridges/builtins.py +2 -0
- package/src/deepscientist/bridges/connectors.py +447 -0
- package/src/deepscientist/channels/__init__.py +2 -0
- package/src/deepscientist/channels/builtins.py +3 -1
- package/src/deepscientist/channels/local.py +3 -3
- package/src/deepscientist/channels/qq.py +8 -8
- package/src/deepscientist/channels/qq_gateway.py +1 -1
- package/src/deepscientist/channels/relay.py +14 -8
- package/src/deepscientist/channels/weixin.py +59 -0
- package/src/deepscientist/channels/weixin_ilink.py +388 -0
- package/src/deepscientist/config/models.py +23 -2
- package/src/deepscientist/config/service.py +539 -67
- package/src/deepscientist/connector/__init__.py +4 -0
- package/src/deepscientist/connector/connector_profiles.py +481 -0
- package/src/deepscientist/connector/lingzhu_support.py +668 -0
- package/src/deepscientist/connector/qq_profiles.py +206 -0
- package/src/deepscientist/connector/weixin_support.py +663 -0
- package/src/deepscientist/connector_profiles.py +1 -374
- package/src/deepscientist/connector_runtime.py +2 -0
- package/src/deepscientist/daemon/api/handlers.py +165 -5
- package/src/deepscientist/daemon/api/router.py +13 -1
- package/src/deepscientist/daemon/app.py +1444 -67
- package/src/deepscientist/doctor.py +4 -5
- package/src/deepscientist/gitops/diff.py +120 -29
- package/src/deepscientist/lingzhu_support.py +1 -182
- package/src/deepscientist/mcp/server.py +135 -7
- package/src/deepscientist/prompts/builder.py +128 -11
- package/src/deepscientist/qq_profiles.py +1 -196
- package/src/deepscientist/quest/node_traces.py +23 -0
- package/src/deepscientist/quest/service.py +359 -74
- package/src/deepscientist/quest/stage_views.py +71 -5
- package/src/deepscientist/runners/codex.py +170 -19
- package/src/deepscientist/runners/runtime_overrides.py +6 -0
- package/src/deepscientist/shared.py +33 -14
- package/src/deepscientist/weixin_support.py +1 -0
- package/src/prompts/connectors/lingzhu.md +3 -1
- package/src/prompts/connectors/qq.md +2 -1
- package/src/prompts/connectors/weixin.md +231 -0
- package/src/prompts/contracts/shared_interaction.md +4 -1
- package/src/prompts/system.md +61 -9
- package/src/skills/analysis-campaign/SKILL.md +46 -6
- package/src/skills/analysis-campaign/references/campaign-plan-template.md +21 -8
- package/src/skills/baseline/SKILL.md +1 -1
- package/src/skills/decision/SKILL.md +1 -1
- package/src/skills/experiment/SKILL.md +1 -1
- package/src/skills/finalize/SKILL.md +1 -1
- package/src/skills/idea/SKILL.md +1 -1
- package/src/skills/intake-audit/SKILL.md +1 -1
- package/src/skills/rebuttal/SKILL.md +74 -1
- package/src/skills/rebuttal/references/response-letter-template.md +55 -11
- package/src/skills/review/SKILL.md +118 -1
- package/src/skills/review/references/experiment-todo-template.md +23 -0
- package/src/skills/review/references/review-report-template.md +16 -0
- package/src/skills/review/references/revision-log-template.md +4 -0
- package/src/skills/scout/SKILL.md +1 -1
- package/src/skills/write/SKILL.md +168 -7
- package/src/skills/write/references/paper-experiment-matrix-template.md +131 -0
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-BKZ103sn.js → AiManusChatView-CnJcXynW.js} +156 -48
- package/src/ui/dist/assets/{AnalysisPlugin-mTTzGAlK.js → AnalysisPlugin-DeyzPEhV.js} +1 -1
- package/src/ui/dist/assets/{CliPlugin-BH58n3GY.js → CliPlugin-CB1YODQn.js} +164 -9
- package/src/ui/dist/assets/{CodeEditorPlugin-BKGRUH7e.js → CodeEditorPlugin-B-xicq1e.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-BMADwFWJ.js → CodeViewerPlugin-DT54ysXa.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-ZOnTIHLN.js → DocViewerPlugin-DQtKT-VD.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-CQ7h1Djm.js → GitDiffViewerPlugin-hqHbCfnv.js} +20 -21
- package/src/ui/dist/assets/{ImageViewerPlugin-GVS5MsnC.js → ImageViewerPlugin-OcVo33jV.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-BZNv1JML.js → LabCopilotPanel-DdGwhEUV.js} +11 -11
- package/src/ui/dist/assets/{LabPlugin-TWcJsdQA.js → LabPlugin-Ciz1gDaX.js} +2 -1
- package/src/ui/dist/assets/{LatexPlugin-DIjHiR2x.js → LatexPlugin-BhmjNQRC.js} +37 -11
- package/src/ui/dist/assets/{MarkdownViewerPlugin-D3ooGAH0.js → MarkdownViewerPlugin-BzdVH9Bx.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-DfVfE9hN.js → MarketplacePlugin-DmyHspXt.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-DDl0_Mc0.js → NotebookEditor-BMXKrDRk.js} +1 -1
- package/src/ui/dist/assets/{NotebookEditor-s8JhzuX1.js → NotebookEditor-BTVYRGkm.js} +12 -12
- package/src/ui/dist/assets/{PdfLoader-C2Sf6SJM.js → PdfLoader-CvcjJHXv.js} +14 -7
- package/src/ui/dist/assets/{PdfMarkdownPlugin-CXFLoIsa.js → PdfMarkdownPlugin-DW2ej8Vk.js} +73 -6
- package/src/ui/dist/assets/{PdfViewerPlugin-BYTmz2fK.js → PdfViewerPlugin-CmlDxbhU.js} +103 -34
- package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
- package/src/ui/dist/assets/{SearchPlugin-CjWBI1O9.js → SearchPlugin-DAjQZPSv.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-DdOBU3-S.js → TextViewerPlugin-C-nVAZb_.js} +5 -4
- package/src/ui/dist/assets/{VNCViewer-B8HGgLwQ.js → VNCViewer-D7-dIYon.js} +10 -10
- package/src/ui/dist/assets/bot-C_G4WtNI.js +21 -0
- package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
- package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
- package/src/ui/dist/assets/{code-BWAY76JP.js → code-Cd7WfiWq.js} +1 -1
- package/src/ui/dist/assets/{file-content-C1NwU5oQ.js → file-content-B57zsL9y.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-CywslwB9.js → file-diff-panel-DVoheLFq.js} +1 -1
- package/src/ui/dist/assets/{file-socket-B4kzuOBQ.js → file-socket-B5kXFxZP.js} +1 -1
- package/src/ui/dist/assets/{image-D-NZM-6P.js → image-LLOjkMHF.js} +1 -1
- package/src/ui/dist/assets/{index-DGIYDuTv.css → index-BQG-1s2o.css} +40 -13
- package/src/ui/dist/assets/{index-DHZJ_0TI.js → index-C3r2iGrp.js} +12 -12
- package/src/ui/dist/assets/{index-7Chr1g9c.js → index-CLQauncb.js} +15050 -9561
- package/src/ui/dist/assets/index-Dxa2eYMY.js +25 -0
- package/src/ui/dist/assets/{index-BdM1Gqfr.js → index-hOUOWbW2.js} +2 -2
- package/src/ui/dist/assets/{monaco-Cb2uKKe6.js → monaco-BGGAEii3.js} +1 -1
- package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DlEr1_y5.js} +16 -1
- package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
- package/src/ui/dist/assets/{popover-Bg72DGgT.js → popover-CWJbJuYY.js} +1 -1
- package/src/ui/dist/assets/{project-sync-Ce_0BglY.js → project-sync-CRJiucYO.js} +18 -77
- package/src/ui/dist/assets/select-CoHB7pvH.js +1690 -0
- package/src/ui/dist/assets/{sigma-DPaACDrh.js → sigma-D5aJWR8J.js} +1 -1
- package/src/ui/dist/assets/{index-CDxNdQdz.js → square-check-big-DUK_mnkS.js} +2 -13
- package/src/ui/dist/assets/{trash-BvTgE5__.js → trash-ChU3SEE3.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-CgPeMOwP.js → useCliAccess-BrJBV3tY.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-xPhz7P5B.js → useFileDiffOverlay-C2OQaVWc.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-C3Un3YQr.js → wrap-text-C7Qqh-om.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-BgxLa0Ri.js → zoom-out-rtX0FKya.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
- package/src/ui/dist/assets/AutoFigurePlugin-C_wWw4AP.js +0 -8149
- package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
- package/src/ui/dist/assets/Stepper-B0Dd8CxK.js +0 -158
- package/src/ui/dist/assets/bibtex-CKaefIN2.js +0 -189
- package/src/ui/dist/assets/file-utils-H2fjA46S.js +0 -109
- package/src/ui/dist/assets/message-square-BzjLiXir.js +0 -16
- package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
- package/src/ui/dist/assets/tooltip-C_mA6R0w.js +0 -108
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import base64
|
|
4
|
+
from collections import deque
|
|
5
|
+
import faulthandler
|
|
4
6
|
import json
|
|
5
7
|
import mimetypes
|
|
6
8
|
import os
|
|
9
|
+
import re
|
|
10
|
+
import signal
|
|
7
11
|
import shutil
|
|
8
12
|
import subprocess
|
|
13
|
+
import sys
|
|
9
14
|
import threading
|
|
10
15
|
import time
|
|
16
|
+
import traceback
|
|
11
17
|
from datetime import UTC, datetime, timedelta
|
|
12
18
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
19
|
from pathlib import Path
|
|
@@ -16,6 +22,7 @@ from urllib.parse import parse_qs, urlencode, urlparse
|
|
|
16
22
|
from urllib.request import Request
|
|
17
23
|
|
|
18
24
|
from .. import __version__
|
|
25
|
+
from ..annotations import AnnotationService
|
|
19
26
|
from ..artifact import ArtifactService
|
|
20
27
|
from ..bash_exec import BashExecService
|
|
21
28
|
from ..bash_exec.runtime import TerminalClient
|
|
@@ -27,9 +34,10 @@ from ..channels.feishu_long_connection import FeishuLongConnectionService
|
|
|
27
34
|
from ..channels.qq_gateway import QQGatewayService
|
|
28
35
|
from ..channels.slack_socket import SlackSocketModeService
|
|
29
36
|
from ..channels.telegram_polling import TelegramPollingService
|
|
37
|
+
from ..channels.weixin_ilink import WeixinIlinkService
|
|
30
38
|
from ..channels.whatsapp_local_session import WhatsAppLocalSessionService
|
|
31
39
|
from ..cloud import CloudLinkService
|
|
32
|
-
from ..connector_profiles import (
|
|
40
|
+
from ..connector.connector_profiles import (
|
|
33
41
|
CONNECTOR_PROFILE_SPECS,
|
|
34
42
|
PROFILEABLE_CONNECTOR_NAMES,
|
|
35
43
|
connector_profile_label,
|
|
@@ -44,15 +52,36 @@ from ..home import repo_root
|
|
|
44
52
|
from ..memory import MemoryService
|
|
45
53
|
from ..network import urlopen_with_proxy as urlopen
|
|
46
54
|
from ..latex_runtime import QuestLatexService
|
|
55
|
+
from ..connector.lingzhu_support import (
|
|
56
|
+
lingzhu_detect_tool_call_from_text,
|
|
57
|
+
lingzhu_extract_task_text,
|
|
58
|
+
lingzhu_extract_user_text,
|
|
59
|
+
lingzhu_health_payload,
|
|
60
|
+
lingzhu_is_passive_conversation_id,
|
|
61
|
+
lingzhu_passive_conversation_id,
|
|
62
|
+
lingzhu_request_conversation_id,
|
|
63
|
+
lingzhu_request_sender_id,
|
|
64
|
+
lingzhu_sse_answer,
|
|
65
|
+
lingzhu_sse_tool_call,
|
|
66
|
+
lingzhu_surface_action_tool_call,
|
|
67
|
+
lingzhu_verify_auth_header,
|
|
68
|
+
)
|
|
47
69
|
from ..prompts import PromptBuilder
|
|
48
70
|
from ..prompts.builder import STANDARD_SKILLS
|
|
49
|
-
from ..qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
|
|
71
|
+
from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
|
|
50
72
|
from ..quest import QuestService
|
|
51
73
|
from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
|
|
52
74
|
from ..runtime_logs import JsonlLogger
|
|
53
|
-
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
|
|
75
|
+
from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, resolve_within, run_command, slugify, utc_now, which, write_json
|
|
54
76
|
from ..skills import SkillInstaller
|
|
55
77
|
from ..team import SingleTeamService
|
|
78
|
+
from ..connector.weixin_support import (
|
|
79
|
+
DEFAULT_WEIXIN_BOT_TYPE,
|
|
80
|
+
fetch_weixin_qrcode,
|
|
81
|
+
normalize_weixin_base_url,
|
|
82
|
+
normalize_weixin_cdn_base_url,
|
|
83
|
+
poll_weixin_qrcode_status,
|
|
84
|
+
)
|
|
56
85
|
from .api import ApiHandlers, match_route
|
|
57
86
|
from .sessions import SessionStore
|
|
58
87
|
from websockets.datastructures import Headers
|
|
@@ -63,6 +92,7 @@ from websockets.sync.server import Server as WebSocketServer
|
|
|
63
92
|
from websockets.sync.server import ServerConnection, serve as websocket_serve
|
|
64
93
|
|
|
65
94
|
TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
|
|
95
|
+
_AUTO_CONTINUE_DELAY_SECONDS = 0.2
|
|
66
96
|
CODEX_RETRY_DEFAULT_MAX_ATTEMPTS = 5
|
|
67
97
|
CODEX_RETRY_DEFAULT_INITIAL_BACKOFF_SEC = 10.0
|
|
68
98
|
CODEX_RETRY_DEFAULT_BACKOFF_MULTIPLIER = 6.0
|
|
@@ -70,6 +100,25 @@ CODEX_RETRY_DEFAULT_MAX_BACKOFF_SEC = 1800.0
|
|
|
70
100
|
LEGACY_CODEX_RETRY_INITIAL_BACKOFF_SEC = 1.0
|
|
71
101
|
LEGACY_CODEX_RETRY_BACKOFF_MULTIPLIER = 2.0
|
|
72
102
|
LEGACY_CODEX_RETRY_MAX_BACKOFF_SEC = 8.0
|
|
103
|
+
_CRASH_AUTO_RESUME_COOLDOWN = timedelta(minutes=10)
|
|
104
|
+
_CRASH_AUTO_RESUME_MAX_RECENT_ATTEMPTS = 2
|
|
105
|
+
_LINGZHU_SHORT_COMMAND_DIRECT_MAP = {
|
|
106
|
+
"帮助": "help",
|
|
107
|
+
"列表": "list",
|
|
108
|
+
"状态": "status",
|
|
109
|
+
"总结": "summary",
|
|
110
|
+
"图谱": "graph",
|
|
111
|
+
"指标": "metrics",
|
|
112
|
+
}
|
|
113
|
+
_LINGZHU_SHORT_COMMAND_PREFIX_MAP = {
|
|
114
|
+
"绑定": "use",
|
|
115
|
+
"新建": "new",
|
|
116
|
+
"删除": "delete",
|
|
117
|
+
"暂停": "stop",
|
|
118
|
+
"恢复": "resume",
|
|
119
|
+
}
|
|
120
|
+
_LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新"}
|
|
121
|
+
_LINGZHU_DELETE_CONFIRM_ALIASES = {"确认", "强制", "--yes", "-y"}
|
|
73
122
|
|
|
74
123
|
|
|
75
124
|
class DaemonApp:
|
|
@@ -88,6 +137,7 @@ class DaemonApp:
|
|
|
88
137
|
self.quest_service = QuestService(home, skill_installer=self.skill_installer)
|
|
89
138
|
self.latex_service = QuestLatexService(self.quest_service)
|
|
90
139
|
self.memory_service = MemoryService(home)
|
|
140
|
+
self.annotation_service = AnnotationService(home)
|
|
91
141
|
self.artifact_service = ArtifactService(home)
|
|
92
142
|
self.bash_exec_service = BashExecService(home)
|
|
93
143
|
self.team_service = SingleTeamService(home)
|
|
@@ -127,6 +177,7 @@ class DaemonApp:
|
|
|
127
177
|
}
|
|
128
178
|
self.channels = {name: self._create_channel(name) for name in list_channel_names()}
|
|
129
179
|
self.sessions = SessionStore()
|
|
180
|
+
self._canonicalize_lingzhu_binding_state()
|
|
130
181
|
self._turn_lock = threading.Lock()
|
|
131
182
|
self._turn_state: dict[str, dict[str, object]] = {}
|
|
132
183
|
self._server: ThreadingHTTPServer | None = None
|
|
@@ -138,11 +189,16 @@ class DaemonApp:
|
|
|
138
189
|
self._serve_port: int | None = None
|
|
139
190
|
self._shutdown_requested = threading.Event()
|
|
140
191
|
self._qq_gateways: dict[str, QQGatewayService] = {}
|
|
192
|
+
self._weixin_ilink: WeixinIlinkService | None = None
|
|
141
193
|
self._telegram_polling: dict[str, TelegramPollingService] = {}
|
|
142
194
|
self._slack_socket: dict[str, SlackSocketModeService] = {}
|
|
143
195
|
self._discord_gateway: dict[str, DiscordGatewayService] = {}
|
|
144
196
|
self._feishu_long_connection: dict[str, FeishuLongConnectionService] = {}
|
|
145
197
|
self._whatsapp_local_session: dict[str, WhatsAppLocalSessionService] = {}
|
|
198
|
+
self._weixin_login_sessions: dict[str, dict[str, Any]] = {}
|
|
199
|
+
self._process_hooks_installed = False
|
|
200
|
+
self._faulthandler_stream = None
|
|
201
|
+
self._recovered_quest_ids: set[str] = set()
|
|
146
202
|
self.handlers = ApiHandlers(self)
|
|
147
203
|
|
|
148
204
|
def list_connector_statuses(self) -> list[dict[str, object]]:
|
|
@@ -150,10 +206,10 @@ class DaemonApp:
|
|
|
150
206
|
items = [
|
|
151
207
|
self._augment_connector_status(channel.status(), title_by_quest=title_by_quest)
|
|
152
208
|
for name, channel in self.channels.items()
|
|
153
|
-
if name == "local" or self._is_connector_system_enabled(name)
|
|
209
|
+
if name == "local" or (name != "lingzhu" and self._is_connector_system_enabled(name))
|
|
154
210
|
]
|
|
155
211
|
lingzhu_config = self.connectors_config.get("lingzhu")
|
|
156
|
-
if isinstance(lingzhu_config, dict)
|
|
212
|
+
if isinstance(lingzhu_config, dict):
|
|
157
213
|
items.append(self._augment_connector_status(self.config_manager.lingzhu_snapshot(lingzhu_config), title_by_quest=title_by_quest))
|
|
158
214
|
return items
|
|
159
215
|
|
|
@@ -333,6 +389,262 @@ class DaemonApp:
|
|
|
333
389
|
"available_connectors": available_connectors,
|
|
334
390
|
}
|
|
335
391
|
|
|
392
|
+
def _log_unhandled_exception(
|
|
393
|
+
self,
|
|
394
|
+
*,
|
|
395
|
+
event_type: str,
|
|
396
|
+
exc_type: type[BaseException],
|
|
397
|
+
exc_value: BaseException,
|
|
398
|
+
exc_traceback: Any,
|
|
399
|
+
thread_name: str | None = None,
|
|
400
|
+
) -> None:
|
|
401
|
+
try:
|
|
402
|
+
traceback_text = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
|
|
403
|
+
except Exception:
|
|
404
|
+
traceback_text = str(exc_value or "")
|
|
405
|
+
payload: dict[str, Any] = {
|
|
406
|
+
"exception_type": getattr(exc_type, "__name__", str(exc_type)),
|
|
407
|
+
"message": str(exc_value),
|
|
408
|
+
"traceback": traceback_text,
|
|
409
|
+
"pid": os.getpid(),
|
|
410
|
+
}
|
|
411
|
+
if thread_name:
|
|
412
|
+
payload["thread_name"] = thread_name
|
|
413
|
+
self.logger.log("error", event_type, **payload)
|
|
414
|
+
|
|
415
|
+
def _handle_process_signal(self, signame: str) -> None:
|
|
416
|
+
self.logger.log(
|
|
417
|
+
"warning",
|
|
418
|
+
"daemon.signal_received",
|
|
419
|
+
signal=signame,
|
|
420
|
+
pid=os.getpid(),
|
|
421
|
+
)
|
|
422
|
+
self.request_shutdown(source=f"signal:{str(signame).lower()}")
|
|
423
|
+
|
|
424
|
+
def _install_process_observability(self) -> None:
|
|
425
|
+
if self._process_hooks_installed:
|
|
426
|
+
return
|
|
427
|
+
self._process_hooks_installed = True
|
|
428
|
+
faulthandler_path = self.home / "logs" / "daemon-faulthandler.log"
|
|
429
|
+
try:
|
|
430
|
+
ensure_dir(faulthandler_path.parent)
|
|
431
|
+
self._faulthandler_stream = open(faulthandler_path, "a", encoding="utf-8")
|
|
432
|
+
faulthandler.enable(file=self._faulthandler_stream)
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
self.logger.log("warning", "daemon.faulthandler_enable_failed", error=str(exc))
|
|
435
|
+
|
|
436
|
+
def _sys_excepthook(exc_type: type[BaseException], exc_value: BaseException, exc_traceback: Any) -> None:
|
|
437
|
+
self._log_unhandled_exception(
|
|
438
|
+
event_type="daemon.unhandled_exception",
|
|
439
|
+
exc_type=exc_type,
|
|
440
|
+
exc_value=exc_value,
|
|
441
|
+
exc_traceback=exc_traceback,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def _thread_excepthook(args: threading.ExceptHookArgs) -> None:
|
|
445
|
+
self._log_unhandled_exception(
|
|
446
|
+
event_type="daemon.thread_exception",
|
|
447
|
+
exc_type=args.exc_type,
|
|
448
|
+
exc_value=args.exc_value,
|
|
449
|
+
exc_traceback=args.exc_traceback,
|
|
450
|
+
thread_name=getattr(args.thread, "name", None),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
sys.excepthook = _sys_excepthook
|
|
454
|
+
threading.excepthook = _thread_excepthook
|
|
455
|
+
for signame in ("SIGTERM", "SIGHUP", "SIGINT"):
|
|
456
|
+
signum = getattr(signal, signame, None)
|
|
457
|
+
if signum is None:
|
|
458
|
+
continue
|
|
459
|
+
try:
|
|
460
|
+
signal.signal(signum, lambda _signum, _frame, _signame=signame: self._handle_process_signal(_signame))
|
|
461
|
+
except Exception as exc:
|
|
462
|
+
self.logger.log(
|
|
463
|
+
"warning",
|
|
464
|
+
"daemon.signal_handler_install_failed",
|
|
465
|
+
signal=signame,
|
|
466
|
+
error=str(exc),
|
|
467
|
+
)
|
|
468
|
+
self.logger.log("info", "daemon.process_hooks_installed", pid=os.getpid())
|
|
469
|
+
|
|
470
|
+
def _resume_reconciled_quests(self) -> list[dict[str, Any]]:
|
|
471
|
+
resumed: list[dict[str, Any]] = []
|
|
472
|
+
for item in self.reconciled_quests:
|
|
473
|
+
if not isinstance(item, dict):
|
|
474
|
+
continue
|
|
475
|
+
quest_id = str(item.get("quest_id") or "").strip()
|
|
476
|
+
if not quest_id or quest_id in self._recovered_quest_ids:
|
|
477
|
+
continue
|
|
478
|
+
if not bool(item.get("recoverable")):
|
|
479
|
+
continue
|
|
480
|
+
recent_attempts = self._recent_crash_auto_resume_count(quest_id)
|
|
481
|
+
if recent_attempts >= _CRASH_AUTO_RESUME_MAX_RECENT_ATTEMPTS:
|
|
482
|
+
self._record_auto_resume_suppressed(
|
|
483
|
+
quest_id=quest_id,
|
|
484
|
+
previous_status=str(item.get("previous_status") or "").strip() or None,
|
|
485
|
+
abandoned_run_id=str(item.get("abandoned_run_id") or "").strip() or None,
|
|
486
|
+
last_transition_at=str(item.get("last_transition_at") or "").strip() or None,
|
|
487
|
+
recent_attempts=recent_attempts,
|
|
488
|
+
)
|
|
489
|
+
continue
|
|
490
|
+
try:
|
|
491
|
+
resume_payload = self.resume_quest(quest_id, source="auto:daemon-recovery")
|
|
492
|
+
snapshot = (
|
|
493
|
+
dict(resume_payload.get("snapshot") or {})
|
|
494
|
+
if isinstance(resume_payload.get("snapshot"), dict)
|
|
495
|
+
else self.quest_service.snapshot(quest_id)
|
|
496
|
+
)
|
|
497
|
+
reason = (
|
|
498
|
+
"queued_user_messages"
|
|
499
|
+
if int(snapshot.get("pending_user_message_count") or 0) > 0
|
|
500
|
+
else "auto_continue"
|
|
501
|
+
)
|
|
502
|
+
scheduled = self.schedule_turn(quest_id, reason=reason)
|
|
503
|
+
event = {
|
|
504
|
+
"event_id": generate_id("evt"),
|
|
505
|
+
"type": "quest.runtime_auto_resumed",
|
|
506
|
+
"quest_id": quest_id,
|
|
507
|
+
"previous_status": item.get("previous_status"),
|
|
508
|
+
"abandoned_run_id": item.get("abandoned_run_id"),
|
|
509
|
+
"last_transition_at": item.get("last_transition_at"),
|
|
510
|
+
"reason": reason,
|
|
511
|
+
"scheduled": bool(scheduled.get("scheduled")),
|
|
512
|
+
"started": bool(scheduled.get("started")),
|
|
513
|
+
"queued": bool(scheduled.get("queued")),
|
|
514
|
+
"created_at": utc_now(),
|
|
515
|
+
}
|
|
516
|
+
append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", event)
|
|
517
|
+
self.logger.log(
|
|
518
|
+
"warning",
|
|
519
|
+
"quest.runtime_auto_resumed",
|
|
520
|
+
quest_id=quest_id,
|
|
521
|
+
previous_status=item.get("previous_status"),
|
|
522
|
+
abandoned_run_id=item.get("abandoned_run_id"),
|
|
523
|
+
last_transition_at=item.get("last_transition_at"),
|
|
524
|
+
reason=reason,
|
|
525
|
+
scheduled=bool(scheduled.get("scheduled")),
|
|
526
|
+
started=bool(scheduled.get("started")),
|
|
527
|
+
queued=bool(scheduled.get("queued")),
|
|
528
|
+
)
|
|
529
|
+
self._recovered_quest_ids.add(quest_id)
|
|
530
|
+
resumed.append(
|
|
531
|
+
{
|
|
532
|
+
"quest_id": quest_id,
|
|
533
|
+
"previous_status": item.get("previous_status"),
|
|
534
|
+
"abandoned_run_id": item.get("abandoned_run_id"),
|
|
535
|
+
"last_transition_at": item.get("last_transition_at"),
|
|
536
|
+
"reason": reason,
|
|
537
|
+
"scheduled": dict(scheduled),
|
|
538
|
+
}
|
|
539
|
+
)
|
|
540
|
+
except Exception as exc:
|
|
541
|
+
self.logger.log(
|
|
542
|
+
"warning",
|
|
543
|
+
"quest.runtime_auto_resume_failed",
|
|
544
|
+
quest_id=quest_id,
|
|
545
|
+
previous_status=item.get("previous_status"),
|
|
546
|
+
abandoned_run_id=item.get("abandoned_run_id"),
|
|
547
|
+
error=str(exc),
|
|
548
|
+
)
|
|
549
|
+
return resumed
|
|
550
|
+
|
|
551
|
+
@staticmethod
|
|
552
|
+
def _parse_event_timestamp(value: Any) -> datetime | None:
|
|
553
|
+
normalized = str(value or "").strip()
|
|
554
|
+
if not normalized:
|
|
555
|
+
return None
|
|
556
|
+
candidate = normalized.replace("Z", "+00:00")
|
|
557
|
+
try:
|
|
558
|
+
parsed = datetime.fromisoformat(candidate)
|
|
559
|
+
except ValueError:
|
|
560
|
+
return None
|
|
561
|
+
if parsed.tzinfo is None:
|
|
562
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
563
|
+
return parsed.astimezone(UTC)
|
|
564
|
+
|
|
565
|
+
def _recent_crash_auto_resume_count(self, quest_id: str) -> int:
|
|
566
|
+
quest_root = self.home / "quests" / quest_id
|
|
567
|
+
events = read_jsonl_tail(quest_root / ".ds" / "events.jsonl", 400)
|
|
568
|
+
now = datetime.now(UTC)
|
|
569
|
+
count = 0
|
|
570
|
+
for event in reversed(events[-200:]):
|
|
571
|
+
if str(event.get("type") or "").strip() != "quest.runtime_auto_resumed":
|
|
572
|
+
continue
|
|
573
|
+
parsed = self._parse_event_timestamp(event.get("created_at"))
|
|
574
|
+
if parsed is None:
|
|
575
|
+
continue
|
|
576
|
+
if now - parsed > _CRASH_AUTO_RESUME_COOLDOWN:
|
|
577
|
+
continue
|
|
578
|
+
count += 1
|
|
579
|
+
return count
|
|
580
|
+
|
|
581
|
+
def _record_auto_resume_suppressed(
|
|
582
|
+
self,
|
|
583
|
+
*,
|
|
584
|
+
quest_id: str,
|
|
585
|
+
previous_status: str | None,
|
|
586
|
+
abandoned_run_id: str | None,
|
|
587
|
+
last_transition_at: str | None,
|
|
588
|
+
recent_attempts: int,
|
|
589
|
+
) -> None:
|
|
590
|
+
summary = self._polite_copy(
|
|
591
|
+
zh=(
|
|
592
|
+
"检测到 quest 在短时间内连续异常恢复,系统已暂时停止自动继续运行,"
|
|
593
|
+
"以避免进入重复崩溃循环。请先检查运行环境或 runner 侧问题,再手动恢复。"
|
|
594
|
+
),
|
|
595
|
+
en=(
|
|
596
|
+
"DeepScientist detected repeated crash recovery attempts in a short window, "
|
|
597
|
+
"so automatic continuation has been paused to avoid a crash loop. "
|
|
598
|
+
"Inspect the runtime or runner path before resuming manually."
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
payload = {
|
|
602
|
+
"event_id": generate_id("evt"),
|
|
603
|
+
"type": "quest.runtime_auto_resume_suppressed",
|
|
604
|
+
"quest_id": quest_id,
|
|
605
|
+
"previous_status": previous_status,
|
|
606
|
+
"abandoned_run_id": abandoned_run_id,
|
|
607
|
+
"last_transition_at": last_transition_at,
|
|
608
|
+
"recent_attempts": recent_attempts,
|
|
609
|
+
"cooldown_minutes": int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
|
|
610
|
+
"summary": summary,
|
|
611
|
+
"created_at": utc_now(),
|
|
612
|
+
}
|
|
613
|
+
append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", payload)
|
|
614
|
+
self.logger.log(
|
|
615
|
+
"warning",
|
|
616
|
+
"quest.runtime_auto_resume_suppressed",
|
|
617
|
+
quest_id=quest_id,
|
|
618
|
+
previous_status=previous_status,
|
|
619
|
+
abandoned_run_id=abandoned_run_id,
|
|
620
|
+
last_transition_at=last_transition_at,
|
|
621
|
+
recent_attempts=recent_attempts,
|
|
622
|
+
cooldown_minutes=int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
|
|
623
|
+
)
|
|
624
|
+
self.quest_service.append_message(
|
|
625
|
+
quest_id,
|
|
626
|
+
role="assistant",
|
|
627
|
+
content=summary,
|
|
628
|
+
source="system-control",
|
|
629
|
+
)
|
|
630
|
+
self._relay_quest_message_to_bound_connectors(
|
|
631
|
+
quest_id,
|
|
632
|
+
message=summary,
|
|
633
|
+
kind="progress",
|
|
634
|
+
response_phase="control",
|
|
635
|
+
importance="warning",
|
|
636
|
+
attachments=[
|
|
637
|
+
{
|
|
638
|
+
"kind": "quest_control",
|
|
639
|
+
"action": "auto_resume_suppressed",
|
|
640
|
+
"previous_status": previous_status,
|
|
641
|
+
"abandoned_run_id": abandoned_run_id,
|
|
642
|
+
"recent_attempts": recent_attempts,
|
|
643
|
+
"cooldown_minutes": int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
|
|
644
|
+
}
|
|
645
|
+
],
|
|
646
|
+
)
|
|
647
|
+
|
|
336
648
|
def _normalize_requested_connector_bindings(
|
|
337
649
|
self,
|
|
338
650
|
requested_connector_bindings: list[dict[str, object]] | None,
|
|
@@ -858,13 +1170,15 @@ class DaemonApp:
|
|
|
858
1170
|
return {
|
|
859
1171
|
name
|
|
860
1172
|
for name in SYSTEM_CONNECTOR_NAMES
|
|
861
|
-
if bool(system_enabled.get(name, name
|
|
1173
|
+
if bool(system_enabled.get(name, name in {"qq", "weixin"}))
|
|
862
1174
|
}
|
|
863
1175
|
|
|
864
1176
|
def _is_connector_system_enabled(self, connector_name: str) -> bool:
|
|
865
1177
|
normalized = str(connector_name or "").strip().lower()
|
|
866
1178
|
if normalized == "local":
|
|
867
1179
|
return True
|
|
1180
|
+
if normalized == "lingzhu":
|
|
1181
|
+
return True
|
|
868
1182
|
enabled = self._system_enabled_connector_names()
|
|
869
1183
|
if normalized in enabled:
|
|
870
1184
|
return True
|
|
@@ -999,10 +1313,13 @@ class DaemonApp:
|
|
|
999
1313
|
preferred_connector_conversation_id: str | None = None,
|
|
1000
1314
|
requested_connector_bindings: list[dict[str, object]] | None = None,
|
|
1001
1315
|
force_connector_rebind: bool = True,
|
|
1316
|
+
auto_bind_latest_connectors: bool = True,
|
|
1002
1317
|
requested_baseline_ref: dict[str, object] | None = None,
|
|
1003
1318
|
startup_contract: dict[str, object] | None = None,
|
|
1004
1319
|
) -> dict:
|
|
1005
1320
|
normalized_requested_bindings = self._normalize_requested_connector_bindings(requested_connector_bindings)
|
|
1321
|
+
if len(normalized_requested_bindings) > 1:
|
|
1322
|
+
raise ValueError("A quest may bind at most one external connector target.")
|
|
1006
1323
|
snapshot = self.quest_service.create(
|
|
1007
1324
|
goal=goal,
|
|
1008
1325
|
title=title,
|
|
@@ -1126,7 +1443,7 @@ class DaemonApp:
|
|
|
1126
1443
|
),
|
|
1127
1444
|
}
|
|
1128
1445
|
)
|
|
1129
|
-
|
|
1446
|
+
elif auto_bind_latest_connectors:
|
|
1130
1447
|
self._auto_bind_connectors_to_latest_quest(
|
|
1131
1448
|
snapshot["quest_id"],
|
|
1132
1449
|
goal=goal,
|
|
@@ -1311,6 +1628,7 @@ class DaemonApp:
|
|
|
1311
1628
|
status=str(snapshot.get("status") or next_status),
|
|
1312
1629
|
interrupted=False,
|
|
1313
1630
|
summary=summary,
|
|
1631
|
+
automated=source.startswith("auto:"),
|
|
1314
1632
|
)
|
|
1315
1633
|
notice = self._announce_control_state(
|
|
1316
1634
|
quest_id,
|
|
@@ -1375,6 +1693,7 @@ class DaemonApp:
|
|
|
1375
1693
|
message = self._control_notice_message(
|
|
1376
1694
|
quest_id,
|
|
1377
1695
|
action=action,
|
|
1696
|
+
source=source,
|
|
1378
1697
|
snapshot=snapshot,
|
|
1379
1698
|
interrupted=interrupted,
|
|
1380
1699
|
cancelled_pending_user_message_count=cancelled_pending_user_message_count,
|
|
@@ -1435,6 +1754,7 @@ class DaemonApp:
|
|
|
1435
1754
|
quest_id: str,
|
|
1436
1755
|
*,
|
|
1437
1756
|
action: str,
|
|
1757
|
+
source: str,
|
|
1438
1758
|
snapshot: dict,
|
|
1439
1759
|
interrupted: bool,
|
|
1440
1760
|
cancelled_pending_user_message_count: int,
|
|
@@ -1453,6 +1773,13 @@ class DaemonApp:
|
|
|
1453
1773
|
en="The current Git branch and worktree were kept intact, and the quest will continue from the existing research context.",
|
|
1454
1774
|
),
|
|
1455
1775
|
]
|
|
1776
|
+
if source.startswith("auto:daemon-recovery"):
|
|
1777
|
+
lines.append(
|
|
1778
|
+
self._polite_copy(
|
|
1779
|
+
zh="检测到 daemon 曾异常退出;当前 quest 已在自动恢复后继续运行。",
|
|
1780
|
+
en="The daemon exited unexpectedly before; this quest has now been recovered automatically and will continue.",
|
|
1781
|
+
)
|
|
1782
|
+
)
|
|
1456
1783
|
elif action == "pause":
|
|
1457
1784
|
lines = [
|
|
1458
1785
|
self._polite_copy(
|
|
@@ -2106,8 +2433,12 @@ class DaemonApp:
|
|
|
2106
2433
|
snapshot = self.quest_service.snapshot(quest_id)
|
|
2107
2434
|
quest_root = Path(snapshot["quest_root"])
|
|
2108
2435
|
workspace_root = Path(str(snapshot.get("current_workspace_root") or snapshot.get("quest_root") or quest_root))
|
|
2109
|
-
|
|
2110
|
-
|
|
2436
|
+
run_events_window: deque[dict[str, Any]] = deque(maxlen=120)
|
|
2437
|
+
for event in iter_jsonl(quest_root / ".ds" / "events.jsonl"):
|
|
2438
|
+
if str(event.get("run_id") or "").strip() != failed_run_id:
|
|
2439
|
+
continue
|
|
2440
|
+
run_events_window.append(event)
|
|
2441
|
+
run_events = list(run_events_window)
|
|
2111
2442
|
|
|
2112
2443
|
recent_messages: list[str] = []
|
|
2113
2444
|
tool_progress: list[dict[str, str]] = []
|
|
@@ -2267,12 +2598,29 @@ class DaemonApp:
|
|
|
2267
2598
|
if int(snapshot.get("pending_user_message_count") or 0) > 0:
|
|
2268
2599
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2269
2600
|
else:
|
|
2270
|
-
self.
|
|
2601
|
+
self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
|
|
2271
2602
|
return
|
|
2272
2603
|
if int(snapshot.get("pending_user_message_count") or 0) > 0:
|
|
2273
2604
|
self.schedule_turn(quest_id, reason="queued_user_messages")
|
|
2274
2605
|
return
|
|
2275
|
-
self.
|
|
2606
|
+
self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
|
|
2607
|
+
|
|
2608
|
+
def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
|
|
2609
|
+
def _delayed() -> None:
|
|
2610
|
+
time.sleep(max(0.0, delay_seconds))
|
|
2611
|
+
if self._turn_stop_requested(quest_id):
|
|
2612
|
+
return
|
|
2613
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
2614
|
+
status = str(snapshot.get("status") or snapshot.get("runtime_status") or "").strip().lower()
|
|
2615
|
+
if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
|
|
2616
|
+
return
|
|
2617
|
+
self.schedule_turn(quest_id, reason=reason)
|
|
2618
|
+
|
|
2619
|
+
threading.Thread(
|
|
2620
|
+
target=_delayed,
|
|
2621
|
+
daemon=True,
|
|
2622
|
+
name=f"deepscientist-turn-delay-{quest_id}",
|
|
2623
|
+
).start()
|
|
2276
2624
|
|
|
2277
2625
|
def _relay_quest_message_to_bound_connectors(
|
|
2278
2626
|
self,
|
|
@@ -2370,6 +2718,176 @@ class DaemonApp:
|
|
|
2370
2718
|
channel = self._channel_with_bindings(connector_name)
|
|
2371
2719
|
return channel.list_bindings()
|
|
2372
2720
|
|
|
2721
|
+
def start_weixin_login_qr(self, *, force: bool = False) -> dict[str, Any]:
|
|
2722
|
+
connectors = self.config_manager.load_named_normalized("connectors")
|
|
2723
|
+
weixin = connectors.get("weixin") if isinstance(connectors.get("weixin"), dict) else {}
|
|
2724
|
+
base_url = normalize_weixin_base_url(weixin.get("base_url"))
|
|
2725
|
+
bot_type = str(weixin.get("bot_type") or DEFAULT_WEIXIN_BOT_TYPE).strip() or DEFAULT_WEIXIN_BOT_TYPE
|
|
2726
|
+
route_tag = str(weixin.get("route_tag") or "").strip() or None
|
|
2727
|
+
qr_payload = fetch_weixin_qrcode(base_url=base_url, bot_type=bot_type, route_tag=route_tag)
|
|
2728
|
+
qrcode_token = str(qr_payload.get("qrcode") or "").strip()
|
|
2729
|
+
qrcode_content = str(qr_payload.get("qrcode_img_content") or qr_payload.get("url") or "").strip()
|
|
2730
|
+
if not qrcode_token or not qrcode_content:
|
|
2731
|
+
raise RuntimeError("Weixin QR login did not return a valid qrcode token or renderable content.")
|
|
2732
|
+
session_key = generate_id("wxqr")
|
|
2733
|
+
self._weixin_login_sessions[session_key] = {
|
|
2734
|
+
"session_key": session_key,
|
|
2735
|
+
"qrcode": qrcode_token,
|
|
2736
|
+
"qrcode_content": qrcode_content,
|
|
2737
|
+
"base_url": base_url,
|
|
2738
|
+
"bot_type": bot_type,
|
|
2739
|
+
"route_tag": route_tag,
|
|
2740
|
+
"started_at": time.time(),
|
|
2741
|
+
"refresh_count": 0,
|
|
2742
|
+
"force": bool(force),
|
|
2743
|
+
}
|
|
2744
|
+
return {
|
|
2745
|
+
"ok": True,
|
|
2746
|
+
"session_key": session_key,
|
|
2747
|
+
"qrcode_content": qrcode_content,
|
|
2748
|
+
"qrcode_url": qrcode_content,
|
|
2749
|
+
"message": "Weixin QR code is ready. Scan it with WeChat to connect DeepScientist.",
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
def wait_weixin_login_qr(self, *, session_key: str, timeout_ms: int = 1_500) -> dict[str, Any]:
|
|
2753
|
+
normalized_session_key = str(session_key or "").strip()
|
|
2754
|
+
if not normalized_session_key:
|
|
2755
|
+
return {
|
|
2756
|
+
"ok": False,
|
|
2757
|
+
"connected": False,
|
|
2758
|
+
"message": "Weixin QR session key is required.",
|
|
2759
|
+
}
|
|
2760
|
+
session = self._weixin_login_sessions.get(normalized_session_key)
|
|
2761
|
+
if not isinstance(session, dict):
|
|
2762
|
+
return {
|
|
2763
|
+
"ok": False,
|
|
2764
|
+
"connected": False,
|
|
2765
|
+
"message": "Weixin QR session was not found. Start a new login first.",
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
deadline = time.time() + max(int(timeout_ms or 1_500), 500) / 1000.0
|
|
2769
|
+
while time.time() < deadline:
|
|
2770
|
+
remaining = max(deadline - time.time(), 1.0)
|
|
2771
|
+
try:
|
|
2772
|
+
status = poll_weixin_qrcode_status(
|
|
2773
|
+
base_url=str(session.get("base_url") or ""),
|
|
2774
|
+
qrcode=str(session.get("qrcode") or ""),
|
|
2775
|
+
route_tag=str(session.get("route_tag") or "").strip() or None,
|
|
2776
|
+
timeout=min(remaining, 35.0),
|
|
2777
|
+
)
|
|
2778
|
+
except Exception as exc:
|
|
2779
|
+
message = str(exc or "").strip().lower()
|
|
2780
|
+
if isinstance(exc, TimeoutError) or "timed out" in message or "timeout" in message:
|
|
2781
|
+
break
|
|
2782
|
+
raise
|
|
2783
|
+
state = str(status.get("status") or "wait").strip().lower() or "wait"
|
|
2784
|
+
session["status"] = state
|
|
2785
|
+
if state == "confirmed":
|
|
2786
|
+
return self._persist_weixin_login_session(session, status)
|
|
2787
|
+
if state == "expired":
|
|
2788
|
+
refreshed = self._refresh_weixin_login_session(session)
|
|
2789
|
+
return {
|
|
2790
|
+
"ok": True,
|
|
2791
|
+
"connected": False,
|
|
2792
|
+
"status": "expired",
|
|
2793
|
+
"session_key": normalized_session_key,
|
|
2794
|
+
"qrcode_content": refreshed.get("qrcode_content"),
|
|
2795
|
+
"qrcode_url": refreshed.get("qrcode_content"),
|
|
2796
|
+
"message": "Weixin QR code expired and was refreshed automatically.",
|
|
2797
|
+
}
|
|
2798
|
+
if state in {"scaned", "scanned"}:
|
|
2799
|
+
return {
|
|
2800
|
+
"ok": True,
|
|
2801
|
+
"connected": False,
|
|
2802
|
+
"status": "scaned",
|
|
2803
|
+
"session_key": normalized_session_key,
|
|
2804
|
+
"qrcode_content": str(session.get("qrcode_content") or "").strip() or None,
|
|
2805
|
+
"qrcode_url": str(session.get("qrcode_content") or "").strip() or None,
|
|
2806
|
+
"message": "QR code scanned. Confirm the login inside WeChat.",
|
|
2807
|
+
}
|
|
2808
|
+
return {
|
|
2809
|
+
"ok": True,
|
|
2810
|
+
"connected": False,
|
|
2811
|
+
"status": str(session.get("status") or "wait").strip() or "wait",
|
|
2812
|
+
"session_key": normalized_session_key,
|
|
2813
|
+
"qrcode_content": str(session.get("qrcode_content") or "").strip() or None,
|
|
2814
|
+
"qrcode_url": str(session.get("qrcode_content") or "").strip() or None,
|
|
2815
|
+
"message": "Waiting for Weixin QR confirmation.",
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
def _refresh_weixin_login_session(self, session: dict[str, Any]) -> dict[str, Any]:
|
|
2819
|
+
qr_payload = fetch_weixin_qrcode(
|
|
2820
|
+
base_url=str(session.get("base_url") or ""),
|
|
2821
|
+
bot_type=str(session.get("bot_type") or DEFAULT_WEIXIN_BOT_TYPE),
|
|
2822
|
+
route_tag=str(session.get("route_tag") or "").strip() or None,
|
|
2823
|
+
)
|
|
2824
|
+
session["qrcode"] = str(qr_payload.get("qrcode") or "").strip()
|
|
2825
|
+
session["qrcode_content"] = str(qr_payload.get("qrcode_img_content") or qr_payload.get("url") or "").strip()
|
|
2826
|
+
session["started_at"] = time.time()
|
|
2827
|
+
session["refresh_count"] = int(session.get("refresh_count") or 0) + 1
|
|
2828
|
+
return session
|
|
2829
|
+
|
|
2830
|
+
def _persist_weixin_login_session(self, session: dict[str, Any], status: dict[str, Any]) -> dict[str, Any]:
|
|
2831
|
+
bot_token = str(status.get("bot_token") or "").strip()
|
|
2832
|
+
account_id = str(status.get("ilink_bot_id") or "").strip()
|
|
2833
|
+
login_user_id = str(status.get("ilink_user_id") or "").strip() or None
|
|
2834
|
+
if not bot_token or not account_id:
|
|
2835
|
+
return {
|
|
2836
|
+
"ok": False,
|
|
2837
|
+
"connected": False,
|
|
2838
|
+
"status": "confirmed",
|
|
2839
|
+
"message": "Weixin QR login confirmed, but the platform did not return `bot_token` or `ilink_bot_id`.",
|
|
2840
|
+
}
|
|
2841
|
+
connectors = self.config_manager.load_named_normalized("connectors")
|
|
2842
|
+
weixin = connectors.get("weixin") if isinstance(connectors.get("weixin"), dict) else {}
|
|
2843
|
+
weixin.update(
|
|
2844
|
+
{
|
|
2845
|
+
"enabled": True,
|
|
2846
|
+
"transport": "ilink_long_poll",
|
|
2847
|
+
"base_url": normalize_weixin_base_url(status.get("baseurl") or session.get("base_url")),
|
|
2848
|
+
"cdn_base_url": normalize_weixin_cdn_base_url(weixin.get("cdn_base_url")),
|
|
2849
|
+
"bot_type": str(session.get("bot_type") or DEFAULT_WEIXIN_BOT_TYPE),
|
|
2850
|
+
"bot_token": bot_token,
|
|
2851
|
+
"account_id": account_id,
|
|
2852
|
+
"login_user_id": login_user_id,
|
|
2853
|
+
}
|
|
2854
|
+
)
|
|
2855
|
+
connectors["weixin"] = weixin
|
|
2856
|
+
save_result = self.config_manager.save_named_payload("connectors", connectors)
|
|
2857
|
+
if not bool(save_result.get("ok")):
|
|
2858
|
+
self.logger.log(
|
|
2859
|
+
"warning",
|
|
2860
|
+
"connector.weixin_qr_persist_failed",
|
|
2861
|
+
session_key=str(session.get("session_key") or ""),
|
|
2862
|
+
account_id=account_id,
|
|
2863
|
+
errors=save_result.get("errors") or [],
|
|
2864
|
+
warnings=save_result.get("warnings") or [],
|
|
2865
|
+
)
|
|
2866
|
+
return {
|
|
2867
|
+
"ok": False,
|
|
2868
|
+
"connected": False,
|
|
2869
|
+
"status": "confirmed",
|
|
2870
|
+
"errors": save_result.get("errors") or [],
|
|
2871
|
+
"warnings": save_result.get("warnings") or [],
|
|
2872
|
+
"message": "Weixin login succeeded, but DeepScientist could not persist the connector config.",
|
|
2873
|
+
}
|
|
2874
|
+
self.reload_connectors_config()
|
|
2875
|
+
self._weixin_login_sessions.pop(str(session.get("session_key") or ""), None)
|
|
2876
|
+
snapshot = next(
|
|
2877
|
+
(item for item in self.list_connector_statuses() if str(item.get("name") or "").strip().lower() == "weixin"),
|
|
2878
|
+
None,
|
|
2879
|
+
)
|
|
2880
|
+
return {
|
|
2881
|
+
"ok": True,
|
|
2882
|
+
"connected": True,
|
|
2883
|
+
"status": "confirmed",
|
|
2884
|
+
"account_id": account_id,
|
|
2885
|
+
"login_user_id": login_user_id,
|
|
2886
|
+
"base_url": str(weixin.get("base_url") or "").strip() or None,
|
|
2887
|
+
"snapshot": snapshot,
|
|
2888
|
+
"message": "Weixin login succeeded and the connector config was saved.",
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2373
2891
|
def delete_connector_profile(self, connector_name: str, profile_id: str) -> dict | tuple[int, dict]:
|
|
2374
2892
|
normalized_connector = str(connector_name or "").strip().lower()
|
|
2375
2893
|
normalized_profile_id = str(profile_id or "").strip()
|
|
@@ -2701,8 +3219,9 @@ class DaemonApp:
|
|
|
2701
3219
|
connector_name = str(parsed.get("connector") or "").strip().lower()
|
|
2702
3220
|
if not connector_name or connector_name == "local" or connector_name not in self.channels:
|
|
2703
3221
|
return 400, {"ok": False, "message": f"Unknown connector `{connector_name}` for conversation `{normalized}`."}
|
|
3222
|
+
binding_conversation_id = self._logical_connector_binding_conversation(connector_name, normalized)
|
|
2704
3223
|
channel = self._channel_with_bindings(connector_name)
|
|
2705
|
-
conflicts = self._inspect_connector_binding_conflicts(quest_id,
|
|
3224
|
+
conflicts = self._inspect_connector_binding_conflicts(quest_id, binding_conversation_id)
|
|
2706
3225
|
if conflicts and not force:
|
|
2707
3226
|
return 409, {
|
|
2708
3227
|
"ok": False,
|
|
@@ -2710,23 +3229,23 @@ class DaemonApp:
|
|
|
2710
3229
|
"message": "Conversation is already bound to another quest.",
|
|
2711
3230
|
"quest_id": quest_id,
|
|
2712
3231
|
"connector": connector_name,
|
|
2713
|
-
"conversation_id":
|
|
3232
|
+
"conversation_id": binding_conversation_id,
|
|
2714
3233
|
"conflicts": conflicts,
|
|
2715
3234
|
}
|
|
2716
|
-
existing_bound = channel.resolve_bound_quest(
|
|
3235
|
+
existing_bound = channel.resolve_bound_quest(binding_conversation_id)
|
|
2717
3236
|
for item in conflicts:
|
|
2718
3237
|
other_id = str(item.get("quest_id") or "").strip()
|
|
2719
3238
|
if other_id and other_id != quest_id:
|
|
2720
|
-
self.quest_service.unbind_source(other_id,
|
|
2721
|
-
self.sessions.unbind(other_id,
|
|
2722
|
-
channel.bind_conversation(
|
|
2723
|
-
self.sessions.bind(quest_id,
|
|
3239
|
+
self.quest_service.unbind_source(other_id, binding_conversation_id)
|
|
3240
|
+
self.sessions.unbind(other_id, binding_conversation_id)
|
|
3241
|
+
channel.bind_conversation(binding_conversation_id, quest_id)
|
|
3242
|
+
self.sessions.bind(quest_id, binding_conversation_id)
|
|
2724
3243
|
self.quest_service.bind_source(quest_id, "local:default")
|
|
2725
|
-
self.quest_service.bind_source(quest_id,
|
|
3244
|
+
self.quest_service.bind_source(quest_id, binding_conversation_id)
|
|
2726
3245
|
if clear_scope == "all_external":
|
|
2727
|
-
removed = self._unbind_external_bindings(quest_id, preserve={
|
|
3246
|
+
removed = self._unbind_external_bindings(quest_id, preserve={binding_conversation_id})
|
|
2728
3247
|
elif clear_scope == "connector":
|
|
2729
|
-
removed = self._unbind_quest_connector_bindings(quest_id, connector_name, preserve={
|
|
3248
|
+
removed = self._unbind_quest_connector_bindings(quest_id, connector_name, preserve={binding_conversation_id})
|
|
2730
3249
|
else:
|
|
2731
3250
|
removed = []
|
|
2732
3251
|
snapshot = self.quest_service.snapshot(quest_id)
|
|
@@ -2743,7 +3262,7 @@ class DaemonApp:
|
|
|
2743
3262
|
"ok": True,
|
|
2744
3263
|
"quest_id": quest_id,
|
|
2745
3264
|
"connector": connector_name,
|
|
2746
|
-
"conversation_id":
|
|
3265
|
+
"conversation_id": binding_conversation_id,
|
|
2747
3266
|
"snapshot": snapshot,
|
|
2748
3267
|
"removed_conversations": removed,
|
|
2749
3268
|
"conflicts_resolved": [item.get("quest_id") for item in conflicts if item.get("quest_id")],
|
|
@@ -2790,7 +3309,7 @@ class DaemonApp:
|
|
|
2790
3309
|
"message": f"Conversation `{normalized}` does not belong to connector `{normalized_connector}`.",
|
|
2791
3310
|
}
|
|
2792
3311
|
|
|
2793
|
-
return self._apply_conversation_binding(quest_id, normalized, force=force, clear_scope="
|
|
3312
|
+
return self._apply_conversation_binding(quest_id, normalized, force=force, clear_scope="all_external")
|
|
2794
3313
|
|
|
2795
3314
|
def update_quest_bindings(
|
|
2796
3315
|
self,
|
|
@@ -2803,6 +3322,12 @@ class DaemonApp:
|
|
|
2803
3322
|
if not quest_root.joinpath("quest.yaml").exists():
|
|
2804
3323
|
return 404, {"ok": False, "message": f"Unknown quest `{quest_id}`."}
|
|
2805
3324
|
normalized_bindings = self._normalize_requested_connector_bindings(requested_bindings)
|
|
3325
|
+
if len(normalized_bindings) > 1:
|
|
3326
|
+
return 400, {
|
|
3327
|
+
"ok": False,
|
|
3328
|
+
"message": "A quest may bind at most one external connector target.",
|
|
3329
|
+
"quest_id": quest_id,
|
|
3330
|
+
}
|
|
2806
3331
|
conflicts = self.preview_connector_binding_conflicts(normalized_bindings, quest_id=quest_id)
|
|
2807
3332
|
if conflicts and not force:
|
|
2808
3333
|
return 409, {
|
|
@@ -2959,12 +3484,22 @@ class DaemonApp:
|
|
|
2959
3484
|
channel = self._channel_with_bindings(connector_name)
|
|
2960
3485
|
connector_label = self._connector_label(connector_name)
|
|
2961
3486
|
conversation_id = str(message.get("conversation_id") or "")
|
|
3487
|
+
binding_conversation_id = self._logical_connector_binding_conversation(connector_name, conversation_id)
|
|
2962
3488
|
text = str(message.get("text") or "").strip()
|
|
2963
3489
|
command_prefix = channel.command_prefix()
|
|
2964
3490
|
quest_id = channel.resolve_bound_quest(conversation_id)
|
|
2965
|
-
|
|
3491
|
+
if quest_id is None and str(connector_name or "").strip().lower() == "lingzhu":
|
|
3492
|
+
quest_id = self._resolve_lingzhu_bound_quest(conversation_id)
|
|
3493
|
+
command_name = ""
|
|
3494
|
+
args: list[str] = []
|
|
2966
3495
|
if text.startswith(command_prefix):
|
|
2967
3496
|
command_name, args = self._parse_prefixed_command(text, command_prefix)
|
|
3497
|
+
elif str(connector_name or "").strip().lower() == "lingzhu":
|
|
3498
|
+
parsed_lingzhu_command = self._parse_lingzhu_short_command(text)
|
|
3499
|
+
if parsed_lingzhu_command is not None:
|
|
3500
|
+
command_name, args = parsed_lingzhu_command
|
|
3501
|
+
|
|
3502
|
+
if command_name:
|
|
2968
3503
|
if command_name == "help":
|
|
2969
3504
|
return channel.send(
|
|
2970
3505
|
{
|
|
@@ -3001,7 +3536,7 @@ class DaemonApp:
|
|
|
3001
3536
|
announce_connector_binding=True,
|
|
3002
3537
|
exclude_conversation_id=conversation_id,
|
|
3003
3538
|
)
|
|
3004
|
-
self.update_quest_binding(created["quest_id"],
|
|
3539
|
+
self.update_quest_binding(created["quest_id"], binding_conversation_id, force=True)
|
|
3005
3540
|
self.submit_user_message(
|
|
3006
3541
|
created["quest_id"],
|
|
3007
3542
|
text=goal_text,
|
|
@@ -3051,7 +3586,23 @@ class DaemonApp:
|
|
|
3051
3586
|
),
|
|
3052
3587
|
}
|
|
3053
3588
|
)
|
|
3054
|
-
self.
|
|
3589
|
+
previous_external = self._quest_external_binding(target_quest)
|
|
3590
|
+
binding_result = self.update_quest_binding(target_quest, binding_conversation_id, force=True)
|
|
3591
|
+
if isinstance(binding_result, tuple):
|
|
3592
|
+
_status, payload = binding_result
|
|
3593
|
+
return channel.send(
|
|
3594
|
+
{
|
|
3595
|
+
"conversation_id": conversation_id,
|
|
3596
|
+
"kind": "ack",
|
|
3597
|
+
"message": str(payload.get("message") or "Unable to switch connector binding."),
|
|
3598
|
+
}
|
|
3599
|
+
)
|
|
3600
|
+
transition = self._binding_transition_summary(
|
|
3601
|
+
quest_id=target_quest,
|
|
3602
|
+
previous_conversation_id=previous_external,
|
|
3603
|
+
current_conversation_id=self._quest_external_binding(target_quest),
|
|
3604
|
+
)
|
|
3605
|
+
self._announce_binding_transition(transition, notify_new=False, notify_old=True)
|
|
3055
3606
|
return channel.send(
|
|
3056
3607
|
{
|
|
3057
3608
|
"conversation_id": conversation_id,
|
|
@@ -3139,9 +3690,22 @@ class DaemonApp:
|
|
|
3139
3690
|
}
|
|
3140
3691
|
)
|
|
3141
3692
|
|
|
3142
|
-
if quest_id is None and command_name
|
|
3693
|
+
if quest_id is None and command_name not in {"help", "projects", "quests", "list", "new", "use", "delete"}:
|
|
3143
3694
|
auto_bound = self._maybe_auto_bind_connector_conversation(connector_name, conversation_id)
|
|
3144
3695
|
if auto_bound is not None:
|
|
3696
|
+
if bool(auto_bound.get("blocked")):
|
|
3697
|
+
return channel.send(
|
|
3698
|
+
{
|
|
3699
|
+
"conversation_id": conversation_id,
|
|
3700
|
+
"kind": "ack",
|
|
3701
|
+
"message": self._connector_switch_required_message(
|
|
3702
|
+
connector_name=connector_name,
|
|
3703
|
+
quest_id=str(auto_bound.get("quest_id") or "").strip(),
|
|
3704
|
+
current_conversation_id=str(auto_bound.get("current_conversation_id") or "").strip(),
|
|
3705
|
+
requested_conversation_id=str(auto_bound.get("requested_conversation_id") or "").strip(),
|
|
3706
|
+
),
|
|
3707
|
+
}
|
|
3708
|
+
)
|
|
3145
3709
|
quest_id = str(auto_bound.get("quest_id") or "").strip() or None
|
|
3146
3710
|
|
|
3147
3711
|
if command_name in {"stop", "resume"}:
|
|
@@ -3172,7 +3736,7 @@ class DaemonApp:
|
|
|
3172
3736
|
target_quest_id = str(target_quest_id or "").strip()
|
|
3173
3737
|
bound_quest_id = str(channel.resolve_bound_quest(conversation_id) or "").strip() or None
|
|
3174
3738
|
self.sessions.bind(target_quest_id, conversation_id)
|
|
3175
|
-
self.quest_service.bind_source(target_quest_id,
|
|
3739
|
+
self.quest_service.bind_source(target_quest_id, binding_conversation_id)
|
|
3176
3740
|
result = self.control_quest(
|
|
3177
3741
|
target_quest_id,
|
|
3178
3742
|
action=command_name,
|
|
@@ -3197,7 +3761,7 @@ class DaemonApp:
|
|
|
3197
3761
|
)
|
|
3198
3762
|
|
|
3199
3763
|
self.sessions.bind(quest_id, conversation_id)
|
|
3200
|
-
self.quest_service.bind_source(quest_id,
|
|
3764
|
+
self.quest_service.bind_source(quest_id, binding_conversation_id)
|
|
3201
3765
|
if command_name == "status":
|
|
3202
3766
|
snapshot = self.quest_service.snapshot(quest_id)
|
|
3203
3767
|
return channel.send(
|
|
@@ -3370,6 +3934,19 @@ class DaemonApp:
|
|
|
3370
3934
|
if quest_id is None:
|
|
3371
3935
|
auto_bound = self._maybe_auto_bind_connector_conversation(connector_name, conversation_id)
|
|
3372
3936
|
if auto_bound is not None:
|
|
3937
|
+
if bool(auto_bound.get("blocked")):
|
|
3938
|
+
return channel.send(
|
|
3939
|
+
{
|
|
3940
|
+
"conversation_id": conversation_id,
|
|
3941
|
+
"kind": "ack",
|
|
3942
|
+
"message": self._connector_switch_required_message(
|
|
3943
|
+
connector_name=connector_name,
|
|
3944
|
+
quest_id=str(auto_bound.get("quest_id") or "").strip(),
|
|
3945
|
+
current_conversation_id=str(auto_bound.get("current_conversation_id") or "").strip(),
|
|
3946
|
+
requested_conversation_id=str(auto_bound.get("requested_conversation_id") or "").strip(),
|
|
3947
|
+
),
|
|
3948
|
+
}
|
|
3949
|
+
)
|
|
3373
3950
|
quest_id = str(auto_bound.get("quest_id") or "").strip() or None
|
|
3374
3951
|
|
|
3375
3952
|
if quest_id is None:
|
|
@@ -3382,6 +3959,7 @@ class DaemonApp:
|
|
|
3382
3959
|
)
|
|
3383
3960
|
|
|
3384
3961
|
self.sessions.bind(quest_id, conversation_id)
|
|
3962
|
+
self.quest_service.bind_source(quest_id, binding_conversation_id)
|
|
3385
3963
|
materialized_attachments = self._materialize_connector_attachments(
|
|
3386
3964
|
quest_id=quest_id,
|
|
3387
3965
|
connector_name=connector_name,
|
|
@@ -3500,13 +4078,35 @@ class DaemonApp:
|
|
|
3500
4078
|
name = str(resolved.get("name") or "").strip()
|
|
3501
4079
|
content_type = str(resolved.get("content_type") or "").strip()
|
|
3502
4080
|
url = str(resolved.get("url") or "").strip()
|
|
4081
|
+
path = str(resolved.get("path") or "").strip()
|
|
3503
4082
|
target_path = batch_root / self._connector_attachment_filename(index=index, name=name, content_type=content_type)
|
|
3504
4083
|
resolved["manifest_path"] = str(batch_root / "manifest.json")
|
|
3505
4084
|
resolved["batch_path"] = str(batch_root)
|
|
3506
|
-
if
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
4085
|
+
if path:
|
|
4086
|
+
try:
|
|
4087
|
+
source_path = Path(path).expanduser()
|
|
4088
|
+
if not source_path.is_absolute():
|
|
4089
|
+
source_path = (quest_root / source_path).resolve()
|
|
4090
|
+
else:
|
|
4091
|
+
source_path = source_path.resolve()
|
|
4092
|
+
if not source_path.exists():
|
|
4093
|
+
raise FileNotFoundError(f"attachment local path does not exist: {source_path}")
|
|
4094
|
+
size_bytes = self._copy_connector_attachment(
|
|
4095
|
+
source_path=source_path,
|
|
4096
|
+
target_path=target_path,
|
|
4097
|
+
)
|
|
4098
|
+
resolved["path"] = str(target_path)
|
|
4099
|
+
resolved["source_path"] = str(source_path)
|
|
4100
|
+
resolved["quest_relative_path"] = str(target_path.relative_to(quest_root))
|
|
4101
|
+
resolved["size_bytes"] = int(size_bytes)
|
|
4102
|
+
resolved["materialized"] = True
|
|
4103
|
+
resolved["downloaded_at"] = utc_now()
|
|
4104
|
+
return resolved
|
|
4105
|
+
except Exception as exc:
|
|
4106
|
+
if not url:
|
|
4107
|
+
resolved["materialized"] = False
|
|
4108
|
+
resolved["download_error"] = str(exc)
|
|
4109
|
+
return resolved
|
|
3510
4110
|
try:
|
|
3511
4111
|
size_bytes = self._download_connector_attachment(
|
|
3512
4112
|
connector_name=connector_name,
|
|
@@ -3526,6 +4126,28 @@ class DaemonApp:
|
|
|
3526
4126
|
resolved["download_error"] = str(exc)
|
|
3527
4127
|
return resolved
|
|
3528
4128
|
|
|
4129
|
+
def _copy_connector_attachment(
|
|
4130
|
+
self,
|
|
4131
|
+
*,
|
|
4132
|
+
source_path: Path,
|
|
4133
|
+
target_path: Path,
|
|
4134
|
+
) -> int:
|
|
4135
|
+
ensure_dir(target_path.parent)
|
|
4136
|
+
total = 0
|
|
4137
|
+
with source_path.open("rb") as source_handle:
|
|
4138
|
+
with target_path.open("wb") as target_handle:
|
|
4139
|
+
while True:
|
|
4140
|
+
chunk = source_handle.read(65536)
|
|
4141
|
+
if not chunk:
|
|
4142
|
+
break
|
|
4143
|
+
total += len(chunk)
|
|
4144
|
+
if total > self._MAX_INBOUND_ATTACHMENT_BYTES:
|
|
4145
|
+
raise ValueError(
|
|
4146
|
+
f"attachment exceeds max inbound size limit ({self._MAX_INBOUND_ATTACHMENT_BYTES} bytes)"
|
|
4147
|
+
)
|
|
4148
|
+
target_handle.write(chunk)
|
|
4149
|
+
return total
|
|
4150
|
+
|
|
3529
4151
|
def _download_connector_attachment(
|
|
3530
4152
|
self,
|
|
3531
4153
|
*,
|
|
@@ -3611,37 +4233,37 @@ class DaemonApp:
|
|
|
3611
4233
|
if not candidates:
|
|
3612
4234
|
return []
|
|
3613
4235
|
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
return
|
|
4236
|
+
preferred_conversation_id = str(self.connector_availability_summary().get("preferred_conversation_id") or "").strip()
|
|
4237
|
+
selected: tuple[str, str] | None = None
|
|
4238
|
+
if preferred_conversation_id:
|
|
4239
|
+
for item in candidates:
|
|
4240
|
+
if conversation_identity_key(item[1]) == conversation_identity_key(preferred_conversation_id):
|
|
4241
|
+
selected = item
|
|
4242
|
+
break
|
|
4243
|
+
if selected is None:
|
|
4244
|
+
selected = candidates[0]
|
|
4245
|
+
|
|
4246
|
+
connector_name, conversation_id = selected
|
|
4247
|
+
result = self._apply_conversation_binding(quest_id, conversation_id, force=True, clear_scope="none")
|
|
4248
|
+
if isinstance(result, tuple):
|
|
4249
|
+
return []
|
|
4250
|
+
bound_conversation = str(result.get("conversation_id") or "").strip() or conversation_id
|
|
4251
|
+
if announce:
|
|
4252
|
+
channel = self._channel_with_bindings(connector_name)
|
|
4253
|
+
channel.send(
|
|
4254
|
+
{
|
|
4255
|
+
"conversation_id": bound_conversation,
|
|
4256
|
+
"quest_id": quest_id,
|
|
4257
|
+
"kind": "ack",
|
|
4258
|
+
"message": self._quest_created_connector_message(
|
|
4259
|
+
connector_name,
|
|
4260
|
+
quest_id=quest_id,
|
|
4261
|
+
goal=goal,
|
|
4262
|
+
previous_quest_id=str(result.get("previous_quest_id") or "").strip() or None,
|
|
4263
|
+
),
|
|
4264
|
+
}
|
|
4265
|
+
)
|
|
4266
|
+
return [bound_conversation]
|
|
3645
4267
|
|
|
3646
4268
|
def _latest_connector_conversation_id(self, connector_name: str) -> str:
|
|
3647
4269
|
candidates = self._latest_connector_conversation_ids(connector_name)
|
|
@@ -3801,12 +4423,24 @@ class DaemonApp:
|
|
|
3801
4423
|
latest_quest_id = self._latest_quest_id()
|
|
3802
4424
|
if latest_quest_id is None:
|
|
3803
4425
|
return None
|
|
3804
|
-
|
|
4426
|
+
normalized_conversation_id = self._logical_connector_binding_conversation(connector_name, conversation_id)
|
|
4427
|
+
current_external = self._quest_external_binding(latest_quest_id)
|
|
4428
|
+
if current_external and conversation_identity_key(current_external) != conversation_identity_key(normalized_conversation_id):
|
|
4429
|
+
return {
|
|
4430
|
+
"ok": False,
|
|
4431
|
+
"blocked": True,
|
|
4432
|
+
"quest_id": latest_quest_id,
|
|
4433
|
+
"current_conversation_id": current_external,
|
|
4434
|
+
"requested_conversation_id": normalized_conversation_id,
|
|
4435
|
+
}
|
|
4436
|
+
result = self.update_quest_binding(latest_quest_id, normalized_conversation_id, force=True)
|
|
3805
4437
|
if isinstance(result, tuple):
|
|
3806
4438
|
return None
|
|
3807
4439
|
return result
|
|
3808
4440
|
|
|
3809
4441
|
def _connector_home_help(self, connector_name: str, *, message: dict) -> str:
|
|
4442
|
+
if str(connector_name or "").strip().lower() == "lingzhu":
|
|
4443
|
+
return self._with_qq_main_chat_notice(message, self._lingzhu_unbound_help_text())
|
|
3810
4444
|
quests = self.quest_service.list_quests()
|
|
3811
4445
|
latest = str(quests[0]["quest_id"]) if quests else "none"
|
|
3812
4446
|
body = self._polite_copy(
|
|
@@ -3839,6 +4473,241 @@ class DaemonApp:
|
|
|
3839
4473
|
)
|
|
3840
4474
|
return self._with_qq_main_chat_notice(message, body)
|
|
3841
4475
|
|
|
4476
|
+
def _quest_external_binding(self, quest_id: str | None) -> str | None:
|
|
4477
|
+
normalized_quest_id = str(quest_id or "").strip()
|
|
4478
|
+
if not normalized_quest_id:
|
|
4479
|
+
return None
|
|
4480
|
+
for source in self.quest_service.binding_sources(normalized_quest_id):
|
|
4481
|
+
parsed = parse_conversation_id(source)
|
|
4482
|
+
if parsed is None:
|
|
4483
|
+
continue
|
|
4484
|
+
if str(parsed.get("connector") or "").strip().lower() == "local":
|
|
4485
|
+
continue
|
|
4486
|
+
return normalize_conversation_id(source)
|
|
4487
|
+
return None
|
|
4488
|
+
|
|
4489
|
+
def _lingzhu_passive_conversation_id(self) -> str | None:
|
|
4490
|
+
lingzhu_config = self.connectors_config.get("lingzhu")
|
|
4491
|
+
resolved = dict(lingzhu_config) if isinstance(lingzhu_config, dict) else {}
|
|
4492
|
+
auth_ak = self.config_manager._secret(resolved, "auth_ak", "auth_ak_env")
|
|
4493
|
+
if not auth_ak:
|
|
4494
|
+
return None
|
|
4495
|
+
return lingzhu_passive_conversation_id(resolved)
|
|
4496
|
+
|
|
4497
|
+
def _logical_connector_binding_conversation(self, connector_name: str, conversation_id: str | None) -> str:
|
|
4498
|
+
normalized_connector = str(connector_name or "").strip().lower()
|
|
4499
|
+
if normalized_connector == "lingzhu":
|
|
4500
|
+
passive_conversation_id = self._lingzhu_passive_conversation_id()
|
|
4501
|
+
if passive_conversation_id:
|
|
4502
|
+
return passive_conversation_id
|
|
4503
|
+
return normalize_conversation_id(conversation_id)
|
|
4504
|
+
|
|
4505
|
+
def _remove_connector_sources_from_quest(self, quest_id: str, connector_name: str) -> None:
|
|
4506
|
+
normalized_connector = str(connector_name or "").strip().lower()
|
|
4507
|
+
if not normalized_connector:
|
|
4508
|
+
return
|
|
4509
|
+
current_sources = self.quest_service.binding_sources(quest_id)
|
|
4510
|
+
filtered_sources = []
|
|
4511
|
+
for source in current_sources:
|
|
4512
|
+
parsed = parse_conversation_id(source)
|
|
4513
|
+
if parsed is not None and str(parsed.get("connector") or "").strip().lower() == normalized_connector:
|
|
4514
|
+
continue
|
|
4515
|
+
filtered_sources.append(source)
|
|
4516
|
+
self.quest_service.set_binding_sources(quest_id, filtered_sources or ["local:default"])
|
|
4517
|
+
|
|
4518
|
+
def _canonicalize_lingzhu_binding_state(self) -> None:
|
|
4519
|
+
passive_conversation_id = self._lingzhu_passive_conversation_id()
|
|
4520
|
+
if not passive_conversation_id:
|
|
4521
|
+
return
|
|
4522
|
+
try:
|
|
4523
|
+
channel = self._channel_with_bindings("lingzhu")
|
|
4524
|
+
except Exception:
|
|
4525
|
+
return
|
|
4526
|
+
bindings = [dict(item) for item in channel.list_bindings() if isinstance(item, dict)]
|
|
4527
|
+
selected_quest_id: str | None = None
|
|
4528
|
+
selected_updated_at = ""
|
|
4529
|
+
quests_with_lingzhu_sources: set[str] = set()
|
|
4530
|
+
|
|
4531
|
+
for item in bindings:
|
|
4532
|
+
quest_id = str(item.get("quest_id") or "").strip()
|
|
4533
|
+
updated_at = str(item.get("updated_at") or "").strip()
|
|
4534
|
+
if quest_id and (updated_at, quest_id) >= (selected_updated_at, str(selected_quest_id or "")):
|
|
4535
|
+
selected_quest_id = quest_id
|
|
4536
|
+
selected_updated_at = updated_at
|
|
4537
|
+
|
|
4538
|
+
for quest in self.quest_service.list_quests():
|
|
4539
|
+
quest_id = str(quest.get("quest_id") or "").strip()
|
|
4540
|
+
if not quest_id:
|
|
4541
|
+
continue
|
|
4542
|
+
sources = self.quest_service.binding_sources(quest_id)
|
|
4543
|
+
if any(
|
|
4544
|
+
(
|
|
4545
|
+
parsed := parse_conversation_id(source)
|
|
4546
|
+
) is not None and str(parsed.get("connector") or "").strip().lower() == "lingzhu"
|
|
4547
|
+
for source in sources
|
|
4548
|
+
):
|
|
4549
|
+
quests_with_lingzhu_sources.add(quest_id)
|
|
4550
|
+
if not selected_quest_id:
|
|
4551
|
+
selected_quest_id = quest_id
|
|
4552
|
+
|
|
4553
|
+
for item in bindings:
|
|
4554
|
+
conversation_id = str(item.get("conversation_id") or "").strip()
|
|
4555
|
+
quest_id = str(item.get("quest_id") or "").strip() or None
|
|
4556
|
+
if not conversation_id:
|
|
4557
|
+
continue
|
|
4558
|
+
channel.unbind_conversation(conversation_id, quest_id=quest_id)
|
|
4559
|
+
if quest_id:
|
|
4560
|
+
self.sessions.unbind(quest_id, conversation_id)
|
|
4561
|
+
|
|
4562
|
+
for quest_id in quests_with_lingzhu_sources:
|
|
4563
|
+
self._remove_connector_sources_from_quest(quest_id, "lingzhu")
|
|
4564
|
+
|
|
4565
|
+
if selected_quest_id:
|
|
4566
|
+
channel.bind_conversation(passive_conversation_id, selected_quest_id)
|
|
4567
|
+
self.quest_service.bind_source(selected_quest_id, "local:default")
|
|
4568
|
+
self.quest_service.bind_source(selected_quest_id, passive_conversation_id)
|
|
4569
|
+
|
|
4570
|
+
def _resolve_lingzhu_bound_quest(self, conversation_id: str) -> str | None:
|
|
4571
|
+
normalized_conversation_id = normalize_conversation_id(conversation_id)
|
|
4572
|
+
channel = self._channel_with_bindings("lingzhu")
|
|
4573
|
+
known_quest_id = str(channel.resolve_bound_quest(normalized_conversation_id) or "").strip() or None
|
|
4574
|
+
if known_quest_id:
|
|
4575
|
+
return known_quest_id
|
|
4576
|
+
passive_conversation_id = self._lingzhu_passive_conversation_id()
|
|
4577
|
+
passive_quest_id = str(channel.resolve_bound_quest(passive_conversation_id) or "").strip() or None
|
|
4578
|
+
if not passive_quest_id:
|
|
4579
|
+
return None
|
|
4580
|
+
return passive_quest_id
|
|
4581
|
+
|
|
4582
|
+
def _connector_target_label(self, conversation_id: str | None) -> str:
|
|
4583
|
+
normalized = normalize_conversation_id(conversation_id)
|
|
4584
|
+
parsed = parse_conversation_id(normalized)
|
|
4585
|
+
if parsed is None:
|
|
4586
|
+
return str(conversation_id or "unknown").strip() or "unknown"
|
|
4587
|
+
connector_label = self._connector_label(str(parsed.get("connector") or "").strip())
|
|
4588
|
+
profile_id = str(parsed.get("profile_id") or "").strip()
|
|
4589
|
+
if lingzhu_is_passive_conversation_id(normalized):
|
|
4590
|
+
agent_id = str(parsed.get("chat_id_raw") or parsed.get("chat_id") or "").strip() or "main"
|
|
4591
|
+
return f"{connector_label} · passive · {agent_id}"
|
|
4592
|
+
chat_id = str(parsed.get("chat_id_raw") or parsed.get("chat_id") or normalized).strip()
|
|
4593
|
+
if profile_id:
|
|
4594
|
+
return f"{connector_label} · {profile_id} · {chat_id}"
|
|
4595
|
+
return f"{connector_label} · {chat_id}"
|
|
4596
|
+
|
|
4597
|
+
def _binding_transition_summary(
|
|
4598
|
+
self,
|
|
4599
|
+
*,
|
|
4600
|
+
quest_id: str,
|
|
4601
|
+
previous_conversation_id: str | None,
|
|
4602
|
+
current_conversation_id: str | None,
|
|
4603
|
+
) -> dict[str, Any]:
|
|
4604
|
+
previous = normalize_conversation_id(previous_conversation_id)
|
|
4605
|
+
current = normalize_conversation_id(current_conversation_id)
|
|
4606
|
+
if conversation_identity_key(previous) == conversation_identity_key(current):
|
|
4607
|
+
mode = "unchanged"
|
|
4608
|
+
elif previous and current:
|
|
4609
|
+
mode = "switch"
|
|
4610
|
+
elif current:
|
|
4611
|
+
mode = "bind"
|
|
4612
|
+
elif previous:
|
|
4613
|
+
mode = "disconnect"
|
|
4614
|
+
else:
|
|
4615
|
+
mode = "unchanged"
|
|
4616
|
+
return {
|
|
4617
|
+
"quest_id": quest_id,
|
|
4618
|
+
"mode": mode,
|
|
4619
|
+
"previous_conversation_id": previous or None,
|
|
4620
|
+
"previous_label": self._connector_target_label(previous) if previous else None,
|
|
4621
|
+
"current_conversation_id": current or None,
|
|
4622
|
+
"current_label": self._connector_target_label(current) if current else None,
|
|
4623
|
+
"changed": mode != "unchanged",
|
|
4624
|
+
}
|
|
4625
|
+
|
|
4626
|
+
def _announce_binding_transition(
|
|
4627
|
+
self,
|
|
4628
|
+
summary: dict[str, Any] | None,
|
|
4629
|
+
*,
|
|
4630
|
+
notify_new: bool,
|
|
4631
|
+
notify_old: bool,
|
|
4632
|
+
) -> None:
|
|
4633
|
+
if not isinstance(summary, dict) or not bool(summary.get("changed")):
|
|
4634
|
+
return
|
|
4635
|
+
quest_id = str(summary.get("quest_id") or "").strip()
|
|
4636
|
+
previous_conversation_id = str(summary.get("previous_conversation_id") or "").strip() or None
|
|
4637
|
+
current_conversation_id = str(summary.get("current_conversation_id") or "").strip() or None
|
|
4638
|
+
previous_label = str(summary.get("previous_label") or "").strip() or None
|
|
4639
|
+
current_label = str(summary.get("current_label") or "").strip() or None
|
|
4640
|
+
mode = str(summary.get("mode") or "").strip()
|
|
4641
|
+
|
|
4642
|
+
if notify_old and previous_conversation_id and conversation_identity_key(previous_conversation_id) != conversation_identity_key(current_conversation_id):
|
|
4643
|
+
old_connector = str((parse_conversation_id(previous_conversation_id) or {}).get("connector") or "").strip().lower()
|
|
4644
|
+
if old_connector and old_connector in self.channels:
|
|
4645
|
+
channel = self._channel_with_bindings(old_connector)
|
|
4646
|
+
if mode == "disconnect":
|
|
4647
|
+
message = self._polite_copy(
|
|
4648
|
+
zh=f"当前已退出 Quest `{quest_id}`,项目已切换为仅本地。",
|
|
4649
|
+
en=f"This conversation is no longer bound to Quest `{quest_id}`. The project is now local only.",
|
|
4650
|
+
)
|
|
4651
|
+
else:
|
|
4652
|
+
message = self._polite_copy(
|
|
4653
|
+
zh=f"当前已退出 Quest `{quest_id}`,后续请在 {current_label} 查看进展。",
|
|
4654
|
+
en=f"This conversation is no longer bound to Quest `{quest_id}`. Continue from {current_label}.",
|
|
4655
|
+
)
|
|
4656
|
+
channel.send(
|
|
4657
|
+
{
|
|
4658
|
+
"conversation_id": previous_conversation_id,
|
|
4659
|
+
"quest_id": quest_id,
|
|
4660
|
+
"kind": "binding_notice",
|
|
4661
|
+
"message": message,
|
|
4662
|
+
}
|
|
4663
|
+
)
|
|
4664
|
+
|
|
4665
|
+
if notify_new and current_conversation_id:
|
|
4666
|
+
new_connector = str((parse_conversation_id(current_conversation_id) or {}).get("connector") or "").strip().lower()
|
|
4667
|
+
if new_connector and new_connector in self.channels:
|
|
4668
|
+
channel = self._channel_with_bindings(new_connector)
|
|
4669
|
+
if mode == "bind":
|
|
4670
|
+
message = self._polite_copy(
|
|
4671
|
+
zh=f"当前已绑定 Quest `{quest_id}`。",
|
|
4672
|
+
en=f"This conversation is now bound to Quest `{quest_id}`.",
|
|
4673
|
+
)
|
|
4674
|
+
elif mode == "switch":
|
|
4675
|
+
message = self._polite_copy(
|
|
4676
|
+
zh=f"当前已绑定 Quest `{quest_id}`,并已从 {previous_label} 切换到当前会话。",
|
|
4677
|
+
en=f"This conversation is now bound to Quest `{quest_id}`, replacing {previous_label}.",
|
|
4678
|
+
)
|
|
4679
|
+
else:
|
|
4680
|
+
message = ""
|
|
4681
|
+
if message:
|
|
4682
|
+
channel.send(
|
|
4683
|
+
{
|
|
4684
|
+
"conversation_id": current_conversation_id,
|
|
4685
|
+
"quest_id": quest_id,
|
|
4686
|
+
"kind": "binding_notice",
|
|
4687
|
+
"message": message,
|
|
4688
|
+
}
|
|
4689
|
+
)
|
|
4690
|
+
|
|
4691
|
+
def _connector_switch_required_message(
|
|
4692
|
+
self,
|
|
4693
|
+
*,
|
|
4694
|
+
connector_name: str,
|
|
4695
|
+
quest_id: str,
|
|
4696
|
+
current_conversation_id: str,
|
|
4697
|
+
requested_conversation_id: str,
|
|
4698
|
+
) -> str:
|
|
4699
|
+
switch_command = f"绑定{quest_id}" if str(connector_name or "").strip().lower() == "lingzhu" else f"/use {quest_id}"
|
|
4700
|
+
return self._polite_copy(
|
|
4701
|
+
zh=(
|
|
4702
|
+
f"当前 Quest `{quest_id}` 已绑定 {self._connector_target_label(current_conversation_id)}。\n"
|
|
4703
|
+
f"如需切换到 {self._connector_target_label(requested_conversation_id)},请发送 `{switch_command}`,或在项目设置里保存切换。"
|
|
4704
|
+
),
|
|
4705
|
+
en=(
|
|
4706
|
+
f"Quest `{quest_id}` is already bound to {self._connector_target_label(current_conversation_id)}.\n"
|
|
4707
|
+
f"To switch to {self._connector_target_label(requested_conversation_id)}, send `{switch_command}` or save the change from project settings."
|
|
4708
|
+
),
|
|
4709
|
+
)
|
|
4710
|
+
|
|
3842
4711
|
def _unbind_external_bindings(self, quest_id: str, *, preserve: set[str] | None = None) -> list[str]:
|
|
3843
4712
|
preserve_keys = {conversation_identity_key(item) for item in (preserve or set()) if item}
|
|
3844
4713
|
removed: list[str] = []
|
|
@@ -3924,6 +4793,46 @@ class DaemonApp:
|
|
|
3924
4793
|
parts = stripped.split()
|
|
3925
4794
|
return parts[0].lower(), parts[1:]
|
|
3926
4795
|
|
|
4796
|
+
@staticmethod
|
|
4797
|
+
def _parse_lingzhu_short_command(text: str) -> tuple[str, list[str]] | None:
|
|
4798
|
+
normalized = re.sub(r"\s+", " ", str(text or "").strip())
|
|
4799
|
+
if not normalized or normalized.startswith("/"):
|
|
4800
|
+
return None
|
|
4801
|
+
direct = _LINGZHU_SHORT_COMMAND_DIRECT_MAP.get(normalized)
|
|
4802
|
+
if direct:
|
|
4803
|
+
return direct, []
|
|
4804
|
+
for prefix, command_name in _LINGZHU_SHORT_COMMAND_PREFIX_MAP.items():
|
|
4805
|
+
if not normalized.startswith(prefix):
|
|
4806
|
+
continue
|
|
4807
|
+
remainder = normalized[len(prefix) :].strip().lstrip("::,,。.;;!!?? ")
|
|
4808
|
+
if command_name == "new":
|
|
4809
|
+
return command_name, [remainder] if remainder else []
|
|
4810
|
+
if command_name == "delete":
|
|
4811
|
+
matched = re.match(r"^(?P<target>\S+)?(?:\s+(?P<confirm>\S+))?$", remainder)
|
|
4812
|
+
target = str((matched.group("target") if matched else "") or "").strip()
|
|
4813
|
+
confirm = str((matched.group("confirm") if matched else "") or "").strip()
|
|
4814
|
+
args: list[str] = []
|
|
4815
|
+
if target:
|
|
4816
|
+
args.append("latest" if target in _LINGZHU_SHORT_LATEST_ALIASES else target)
|
|
4817
|
+
if confirm in _LINGZHU_DELETE_CONFIRM_ALIASES:
|
|
4818
|
+
args.append("--yes")
|
|
4819
|
+
return command_name, args
|
|
4820
|
+
if remainder:
|
|
4821
|
+
return command_name, ["latest" if remainder in _LINGZHU_SHORT_LATEST_ALIASES else remainder]
|
|
4822
|
+
return command_name, []
|
|
4823
|
+
return None
|
|
4824
|
+
|
|
4825
|
+
def _lingzhu_unbound_help_text(self) -> str:
|
|
4826
|
+
latest = str(self._latest_quest_id() or "none")
|
|
4827
|
+
return (
|
|
4828
|
+
"当前还没绑定 Quest。\n"
|
|
4829
|
+
"可直接说:帮助、列表、绑定025、绑定最新、新建 复现一个 baseline。\n"
|
|
4830
|
+
f"当前最新 Quest:`{latest}`。\n"
|
|
4831
|
+
"绑定后再说:我现在的任务是 ……\n"
|
|
4832
|
+
"查看进展可说:继续 或 汇报。\n"
|
|
4833
|
+
"快捷指令:状态、总结、暂停、恢复、删除025。"
|
|
4834
|
+
)
|
|
4835
|
+
|
|
3927
4836
|
def _maybe_bind_qq_main_chat(self, message: dict) -> dict | None:
|
|
3928
4837
|
chat_type = str(message.get("chat_type") or "").strip().lower()
|
|
3929
4838
|
if chat_type != "direct":
|
|
@@ -4053,6 +4962,20 @@ class DaemonApp:
|
|
|
4053
4962
|
)
|
|
4054
4963
|
if gateway.start():
|
|
4055
4964
|
self._qq_gateways[profile_id] = gateway
|
|
4965
|
+
weixin_config = self.connectors_config.get("weixin", {})
|
|
4966
|
+
if self._is_connector_system_enabled("weixin") and isinstance(weixin_config, dict) and self._weixin_ilink is None:
|
|
4967
|
+
weixin = WeixinIlinkService(
|
|
4968
|
+
home=self.home,
|
|
4969
|
+
config=weixin_config,
|
|
4970
|
+
on_event=lambda event: self.handle_connector_inbound("weixin", event),
|
|
4971
|
+
log=lambda level, message: self.logger.log(
|
|
4972
|
+
level,
|
|
4973
|
+
"connector.weixin_ilink",
|
|
4974
|
+
message=message,
|
|
4975
|
+
),
|
|
4976
|
+
)
|
|
4977
|
+
if weixin.start():
|
|
4978
|
+
self._weixin_ilink = weixin
|
|
4056
4979
|
if self._is_connector_system_enabled("telegram") and not self._telegram_polling:
|
|
4057
4980
|
for profile_id, profile_label, profile_config in self._profiled_connector_configs("telegram"):
|
|
4058
4981
|
polling = TelegramPollingService(
|
|
@@ -4149,6 +5072,10 @@ class DaemonApp:
|
|
|
4149
5072
|
self._qq_gateways = {}
|
|
4150
5073
|
for gateway in gateways:
|
|
4151
5074
|
gateway.stop()
|
|
5075
|
+
weixin = self._weixin_ilink
|
|
5076
|
+
self._weixin_ilink = None
|
|
5077
|
+
if weixin is not None:
|
|
5078
|
+
weixin.stop()
|
|
4152
5079
|
polling = list(self._telegram_polling.values())
|
|
4153
5080
|
self._telegram_polling = {}
|
|
4154
5081
|
for item in polling:
|
|
@@ -4263,6 +5190,435 @@ class DaemonApp:
|
|
|
4263
5190
|
accept = str(headers.get("Accept") or headers.get("accept") or "").lower()
|
|
4264
5191
|
return stream_value in {"1", "true", "yes", "stream"} or "text/event-stream" in accept
|
|
4265
5192
|
|
|
5193
|
+
def lingzhu_health_payload(self) -> dict[str, Any]:
|
|
5194
|
+
config = self.connectors_config.get("lingzhu")
|
|
5195
|
+
resolved = dict(config) if isinstance(config, dict) else {}
|
|
5196
|
+
return lingzhu_health_payload(resolved, chat_completions_enabled=True)
|
|
5197
|
+
|
|
5198
|
+
def _lingzhu_state_path(self) -> Path:
|
|
5199
|
+
return self.home / "logs" / "connectors" / "lingzhu" / "metis_state.json"
|
|
5200
|
+
|
|
5201
|
+
def _read_lingzhu_state(self) -> dict[str, Any]:
|
|
5202
|
+
payload = read_json(self._lingzhu_state_path(), {"delivered_counts": {}})
|
|
5203
|
+
if not isinstance(payload, dict):
|
|
5204
|
+
payload = {}
|
|
5205
|
+
delivered_counts = payload.get("delivered_counts")
|
|
5206
|
+
if not isinstance(delivered_counts, dict):
|
|
5207
|
+
delivered_counts = {}
|
|
5208
|
+
return {"delivered_counts": delivered_counts}
|
|
5209
|
+
|
|
5210
|
+
def _write_lingzhu_state(self, payload: dict[str, Any]) -> None:
|
|
5211
|
+
path = self._lingzhu_state_path()
|
|
5212
|
+
ensure_dir(path.parent)
|
|
5213
|
+
write_json(path, payload)
|
|
5214
|
+
|
|
5215
|
+
def _lingzhu_delivered_count(self, conversation_id: str) -> int:
|
|
5216
|
+
delivered_counts = self._read_lingzhu_state().get("delivered_counts") or {}
|
|
5217
|
+
raw_value = delivered_counts.get(conversation_identity_key(conversation_id))
|
|
5218
|
+
try:
|
|
5219
|
+
return max(0, int(raw_value))
|
|
5220
|
+
except (TypeError, ValueError):
|
|
5221
|
+
return 0
|
|
5222
|
+
|
|
5223
|
+
def _set_lingzhu_delivered_count(self, conversation_id: str, delivered_count: int) -> None:
|
|
5224
|
+
state = self._read_lingzhu_state()
|
|
5225
|
+
counts = dict(state.get("delivered_counts") or {})
|
|
5226
|
+
counts[conversation_identity_key(conversation_id)] = max(0, int(delivered_count))
|
|
5227
|
+
state["delivered_counts"] = counts
|
|
5228
|
+
self._write_lingzhu_state(state)
|
|
5229
|
+
|
|
5230
|
+
def _lingzhu_outbox_records(self, conversation_id: str) -> list[dict[str, Any]]:
|
|
5231
|
+
outbox_path = self.home / "logs" / "connectors" / "lingzhu" / "outbox.jsonl"
|
|
5232
|
+
target_key = conversation_identity_key(conversation_id)
|
|
5233
|
+
items: list[dict[str, Any]] = []
|
|
5234
|
+
for record in read_jsonl(outbox_path):
|
|
5235
|
+
if not isinstance(record, dict):
|
|
5236
|
+
continue
|
|
5237
|
+
current_conversation_id = str(record.get("conversation_id") or "").strip()
|
|
5238
|
+
if not current_conversation_id:
|
|
5239
|
+
continue
|
|
5240
|
+
if conversation_identity_key(current_conversation_id) != target_key:
|
|
5241
|
+
continue
|
|
5242
|
+
text = str(record.get("text") or "").strip()
|
|
5243
|
+
if not text:
|
|
5244
|
+
continue
|
|
5245
|
+
items.append(dict(record))
|
|
5246
|
+
return items
|
|
5247
|
+
|
|
5248
|
+
def _lingzhu_pending_outbox_records(
|
|
5249
|
+
self,
|
|
5250
|
+
conversation_id: str,
|
|
5251
|
+
*,
|
|
5252
|
+
delivered_count: int | None = None,
|
|
5253
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
5254
|
+
records = self._lingzhu_outbox_records(conversation_id)
|
|
5255
|
+
baseline = self._lingzhu_delivered_count(conversation_id) if delivered_count is None else delivered_count
|
|
5256
|
+
applied_baseline = max(0, min(int(baseline), len(records)))
|
|
5257
|
+
return records[applied_baseline:], len(records)
|
|
5258
|
+
|
|
5259
|
+
@staticmethod
|
|
5260
|
+
def _lingzhu_wait_timeout_seconds(config: dict[str, Any]) -> float:
|
|
5261
|
+
try:
|
|
5262
|
+
timeout_ms = int(config.get("request_timeout_ms") or 60000)
|
|
5263
|
+
except (TypeError, ValueError):
|
|
5264
|
+
timeout_ms = 60000
|
|
5265
|
+
timeout_ms = max(15000, min(timeout_ms, 120000))
|
|
5266
|
+
return timeout_ms / 1000.0
|
|
5267
|
+
|
|
5268
|
+
def _lingzhu_wait_for_outbox_records(
|
|
5269
|
+
self,
|
|
5270
|
+
conversation_id: str,
|
|
5271
|
+
*,
|
|
5272
|
+
delivered_count: int,
|
|
5273
|
+
timeout_seconds: float,
|
|
5274
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
5275
|
+
deadline = time.monotonic() + max(0.1, timeout_seconds)
|
|
5276
|
+
while time.monotonic() < deadline:
|
|
5277
|
+
pending_records, total_count = self._lingzhu_pending_outbox_records(
|
|
5278
|
+
conversation_id,
|
|
5279
|
+
delivered_count=delivered_count,
|
|
5280
|
+
)
|
|
5281
|
+
if pending_records:
|
|
5282
|
+
return pending_records, total_count
|
|
5283
|
+
time.sleep(0.25)
|
|
5284
|
+
return self._lingzhu_pending_outbox_records(conversation_id, delivered_count=delivered_count)
|
|
5285
|
+
|
|
5286
|
+
def _lingzhu_emit_outbox_records(
|
|
5287
|
+
self,
|
|
5288
|
+
handler: BaseHTTPRequestHandler,
|
|
5289
|
+
*,
|
|
5290
|
+
message_id: str,
|
|
5291
|
+
agent_id: str,
|
|
5292
|
+
records: list[dict[str, Any]],
|
|
5293
|
+
config: dict[str, Any] | None = None,
|
|
5294
|
+
) -> int:
|
|
5295
|
+
emitted = 0
|
|
5296
|
+
resolved = dict(config or {})
|
|
5297
|
+
default_navigation_mode = str(resolved.get("default_navigation_mode") or "0").strip() or "0"
|
|
5298
|
+
experimental_enabled = bool(resolved.get("enable_experimental_native_actions", False))
|
|
5299
|
+
for record in records:
|
|
5300
|
+
raw_text = str(record.get("text") or "").strip()
|
|
5301
|
+
detected_tool_call = None
|
|
5302
|
+
text = raw_text
|
|
5303
|
+
if raw_text:
|
|
5304
|
+
detected_tool_call, text = lingzhu_detect_tool_call_from_text(
|
|
5305
|
+
raw_text,
|
|
5306
|
+
default_navigation_mode=default_navigation_mode,
|
|
5307
|
+
experimental_enabled=experimental_enabled,
|
|
5308
|
+
)
|
|
5309
|
+
if text:
|
|
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=text,
|
|
5317
|
+
is_finish=True,
|
|
5318
|
+
),
|
|
5319
|
+
)
|
|
5320
|
+
emitted += 1
|
|
5321
|
+
emitted_tool_call = False
|
|
5322
|
+
for action in record.get("surface_actions") or []:
|
|
5323
|
+
tool_call = lingzhu_surface_action_tool_call(
|
|
5324
|
+
action,
|
|
5325
|
+
default_navigation_mode=default_navigation_mode,
|
|
5326
|
+
experimental_enabled=experimental_enabled,
|
|
5327
|
+
)
|
|
5328
|
+
if not tool_call:
|
|
5329
|
+
continue
|
|
5330
|
+
self._write_sse_event(
|
|
5331
|
+
handler,
|
|
5332
|
+
event="message",
|
|
5333
|
+
data=lingzhu_sse_tool_call(
|
|
5334
|
+
message_id=message_id,
|
|
5335
|
+
agent_id=agent_id,
|
|
5336
|
+
tool_call=tool_call,
|
|
5337
|
+
is_finish=True,
|
|
5338
|
+
),
|
|
5339
|
+
)
|
|
5340
|
+
emitted += 1
|
|
5341
|
+
emitted_tool_call = True
|
|
5342
|
+
if not emitted_tool_call and detected_tool_call:
|
|
5343
|
+
self._write_sse_event(
|
|
5344
|
+
handler,
|
|
5345
|
+
event="message",
|
|
5346
|
+
data=lingzhu_sse_tool_call(
|
|
5347
|
+
message_id=message_id,
|
|
5348
|
+
agent_id=agent_id,
|
|
5349
|
+
tool_call=detected_tool_call,
|
|
5350
|
+
is_finish=True,
|
|
5351
|
+
),
|
|
5352
|
+
)
|
|
5353
|
+
emitted += 1
|
|
5354
|
+
return emitted
|
|
5355
|
+
|
|
5356
|
+
def _lingzhu_short_status_text(self, quest_id: str | None) -> str:
|
|
5357
|
+
if not quest_id:
|
|
5358
|
+
return self._lingzhu_unbound_help_text()
|
|
5359
|
+
snapshot = self.quest_service.snapshot(quest_id)
|
|
5360
|
+
runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
|
|
5361
|
+
if runtime_status in {"running", "active"}:
|
|
5362
|
+
return "进行中"
|
|
5363
|
+
if runtime_status == "waiting_for_user":
|
|
5364
|
+
return "等你确认"
|
|
5365
|
+
if runtime_status in {"paused", "stopped"}:
|
|
5366
|
+
return "已暂停"
|
|
5367
|
+
if runtime_status == "completed":
|
|
5368
|
+
return "已完成"
|
|
5369
|
+
if runtime_status == "error":
|
|
5370
|
+
return "出错了"
|
|
5371
|
+
return "暂无新进展"
|
|
5372
|
+
|
|
5373
|
+
@staticmethod
|
|
5374
|
+
def _lingzhu_reply_payload(result: dict[str, Any]) -> tuple[str, str | None, str]:
|
|
5375
|
+
if not isinstance(result, dict):
|
|
5376
|
+
return "", None, ""
|
|
5377
|
+
reply = result.get("reply")
|
|
5378
|
+
if not isinstance(reply, dict):
|
|
5379
|
+
return "", None, ""
|
|
5380
|
+
payload = reply.get("payload")
|
|
5381
|
+
if not isinstance(payload, dict):
|
|
5382
|
+
return "", None, ""
|
|
5383
|
+
text = str(payload.get("text") or payload.get("message") or "").strip()
|
|
5384
|
+
quest_id = str(payload.get("quest_id") or "").strip() or None
|
|
5385
|
+
kind = str(payload.get("kind") or "").strip()
|
|
5386
|
+
return text, quest_id, kind
|
|
5387
|
+
|
|
5388
|
+
def stream_lingzhu_sse(
|
|
5389
|
+
self,
|
|
5390
|
+
handler: BaseHTTPRequestHandler,
|
|
5391
|
+
*,
|
|
5392
|
+
raw_body: bytes,
|
|
5393
|
+
headers: dict[str, str],
|
|
5394
|
+
) -> None:
|
|
5395
|
+
config = self.connectors_config.get("lingzhu")
|
|
5396
|
+
resolved = dict(config) if isinstance(config, dict) else {}
|
|
5397
|
+
if resolved.get("enabled") is False:
|
|
5398
|
+
handler.send_response(503)
|
|
5399
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
5400
|
+
handler.end_headers()
|
|
5401
|
+
handler.wfile.write(json.dumps({"error": "Lingzhu connector is disabled"}, ensure_ascii=False).encode("utf-8"))
|
|
5402
|
+
return
|
|
5403
|
+
|
|
5404
|
+
auth_ak = self.config_manager._secret(resolved, "auth_ak", "auth_ak_env")
|
|
5405
|
+
auth_header = headers.get("Authorization") or headers.get("authorization") or ""
|
|
5406
|
+
if not lingzhu_verify_auth_header(auth_header, auth_ak):
|
|
5407
|
+
handler.send_response(401)
|
|
5408
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
5409
|
+
handler.end_headers()
|
|
5410
|
+
handler.wfile.write(json.dumps({"error": "Unauthorized"}, ensure_ascii=False).encode("utf-8"))
|
|
5411
|
+
return
|
|
5412
|
+
|
|
5413
|
+
try:
|
|
5414
|
+
body = self.handlers.parse_body(raw_body)
|
|
5415
|
+
except Exception:
|
|
5416
|
+
handler.send_response(400)
|
|
5417
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
5418
|
+
handler.end_headers()
|
|
5419
|
+
handler.wfile.write(json.dumps({"error": "Invalid JSON body"}, ensure_ascii=False).encode("utf-8"))
|
|
5420
|
+
return
|
|
5421
|
+
|
|
5422
|
+
if not isinstance(body, dict):
|
|
5423
|
+
handler.send_response(400)
|
|
5424
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
5425
|
+
handler.end_headers()
|
|
5426
|
+
handler.wfile.write(json.dumps({"error": "Request body must be a JSON object"}, ensure_ascii=False).encode("utf-8"))
|
|
5427
|
+
return
|
|
5428
|
+
|
|
5429
|
+
message_id = str(body.get("message_id") or body.get("request_id") or generate_id("lingzhu")).strip()
|
|
5430
|
+
agent_id = str(body.get("agent_id") or resolved.get("agent_id") or "main").strip() or "main"
|
|
5431
|
+
messages = body.get("message")
|
|
5432
|
+
if not isinstance(messages, list):
|
|
5433
|
+
messages = body.get("messages")
|
|
5434
|
+
if not isinstance(messages, list):
|
|
5435
|
+
text = str(body.get("text") or body.get("content") or "").strip()
|
|
5436
|
+
messages = [{"role": "user", "type": "text", "text": text}] if text else None
|
|
5437
|
+
if not isinstance(messages, list):
|
|
5438
|
+
handler.send_response(400)
|
|
5439
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
5440
|
+
handler.end_headers()
|
|
5441
|
+
handler.wfile.write(
|
|
5442
|
+
json.dumps({"error": "Missing required fields: message or messages"}, ensure_ascii=False).encode("utf-8")
|
|
5443
|
+
)
|
|
5444
|
+
return
|
|
5445
|
+
|
|
5446
|
+
conversation_id = lingzhu_request_conversation_id(body)
|
|
5447
|
+
binding_conversation_id = self._logical_connector_binding_conversation("lingzhu", conversation_id)
|
|
5448
|
+
sender_id = lingzhu_request_sender_id(body)
|
|
5449
|
+
inbound_text = lingzhu_extract_user_text(messages) or self._polite_copy(
|
|
5450
|
+
zh="你好,请继续。",
|
|
5451
|
+
en="Hello, please continue.",
|
|
5452
|
+
)
|
|
5453
|
+
channel = self._channel_with_bindings("lingzhu")
|
|
5454
|
+
known_quest_id = self._resolve_lingzhu_bound_quest(conversation_id)
|
|
5455
|
+
delivered_count = self._lingzhu_delivered_count(conversation_id)
|
|
5456
|
+
task_text = lingzhu_extract_task_text(inbound_text)
|
|
5457
|
+
is_command = inbound_text.startswith(channel.command_prefix()) or self._parse_lingzhu_short_command(inbound_text) is not None
|
|
5458
|
+
|
|
5459
|
+
inbound_payload = {
|
|
5460
|
+
"conversation_id": conversation_id,
|
|
5461
|
+
"chat_type": "direct",
|
|
5462
|
+
"message_id": message_id,
|
|
5463
|
+
"sender_id": sender_id,
|
|
5464
|
+
"sender_name": sender_id,
|
|
5465
|
+
"user_id": sender_id,
|
|
5466
|
+
"direct_id": sender_id,
|
|
5467
|
+
"text": inbound_text,
|
|
5468
|
+
"message": inbound_text,
|
|
5469
|
+
"content": inbound_text,
|
|
5470
|
+
"raw_event": body,
|
|
5471
|
+
"metadata": body.get("metadata"),
|
|
5472
|
+
}
|
|
5473
|
+
|
|
5474
|
+
handler.send_response(200)
|
|
5475
|
+
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
5476
|
+
handler.send_header("Cache-Control", "no-cache")
|
|
5477
|
+
handler.send_header("Connection", "close")
|
|
5478
|
+
handler.send_header("X-Accel-Buffering", "no")
|
|
5479
|
+
handler.end_headers()
|
|
5480
|
+
|
|
5481
|
+
try:
|
|
5482
|
+
handler.wfile.write(b": keepalive\n\n")
|
|
5483
|
+
handler.wfile.flush()
|
|
5484
|
+
|
|
5485
|
+
if is_command:
|
|
5486
|
+
result = self.handle_connector_inbound("lingzhu", inbound_payload)
|
|
5487
|
+
reply_text, _, _ = self._lingzhu_reply_payload(result)
|
|
5488
|
+
pending_records, total_count = self._lingzhu_pending_outbox_records(
|
|
5489
|
+
conversation_id,
|
|
5490
|
+
delivered_count=delivered_count,
|
|
5491
|
+
)
|
|
5492
|
+
emitted = self._lingzhu_emit_outbox_records(
|
|
5493
|
+
handler,
|
|
5494
|
+
message_id=message_id,
|
|
5495
|
+
agent_id=agent_id,
|
|
5496
|
+
records=pending_records,
|
|
5497
|
+
config=resolved,
|
|
5498
|
+
)
|
|
5499
|
+
if emitted:
|
|
5500
|
+
self._set_lingzhu_delivered_count(conversation_id, total_count)
|
|
5501
|
+
else:
|
|
5502
|
+
answer_text = reply_text
|
|
5503
|
+
if not answer_text:
|
|
5504
|
+
if not bool(result.get("accepted", False)):
|
|
5505
|
+
reason = str(result.get("reason") or (result.get("normalized") or {}).get("reason") or "").strip()
|
|
5506
|
+
answer_text = reason or "请求未接受"
|
|
5507
|
+
else:
|
|
5508
|
+
answer_text = "已收到"
|
|
5509
|
+
self._write_sse_event(
|
|
5510
|
+
handler,
|
|
5511
|
+
event="message",
|
|
5512
|
+
data=lingzhu_sse_answer(
|
|
5513
|
+
message_id=message_id,
|
|
5514
|
+
agent_id=agent_id,
|
|
5515
|
+
answer_stream=answer_text,
|
|
5516
|
+
is_finish=True,
|
|
5517
|
+
),
|
|
5518
|
+
)
|
|
5519
|
+
handler.close_connection = True
|
|
5520
|
+
return
|
|
5521
|
+
|
|
5522
|
+
if task_text is not None:
|
|
5523
|
+
target_quest_id = known_quest_id
|
|
5524
|
+
if target_quest_id:
|
|
5525
|
+
self.sessions.bind(target_quest_id, conversation_id)
|
|
5526
|
+
self.quest_service.bind_source(target_quest_id, binding_conversation_id)
|
|
5527
|
+
pending_before, total_before = self._lingzhu_pending_outbox_records(
|
|
5528
|
+
conversation_id,
|
|
5529
|
+
delivered_count=delivered_count,
|
|
5530
|
+
)
|
|
5531
|
+
emitted_before = self._lingzhu_emit_outbox_records(
|
|
5532
|
+
handler,
|
|
5533
|
+
message_id=message_id,
|
|
5534
|
+
agent_id=agent_id,
|
|
5535
|
+
records=pending_before,
|
|
5536
|
+
config=resolved,
|
|
5537
|
+
)
|
|
5538
|
+
if emitted_before:
|
|
5539
|
+
delivered_count = total_before
|
|
5540
|
+
self._set_lingzhu_delivered_count(conversation_id, total_before)
|
|
5541
|
+
|
|
5542
|
+
if not target_quest_id:
|
|
5543
|
+
self._write_sse_event(
|
|
5544
|
+
handler,
|
|
5545
|
+
event="message",
|
|
5546
|
+
data=lingzhu_sse_answer(
|
|
5547
|
+
message_id=message_id,
|
|
5548
|
+
agent_id=agent_id,
|
|
5549
|
+
answer_stream=self._lingzhu_unbound_help_text(),
|
|
5550
|
+
is_finish=True,
|
|
5551
|
+
),
|
|
5552
|
+
)
|
|
5553
|
+
handler.close_connection = True
|
|
5554
|
+
return
|
|
5555
|
+
|
|
5556
|
+
self.submit_user_message(
|
|
5557
|
+
target_quest_id,
|
|
5558
|
+
text=task_text,
|
|
5559
|
+
source=conversation_id,
|
|
5560
|
+
client_message_id=message_id,
|
|
5561
|
+
)
|
|
5562
|
+
pending_after, total_after = self._lingzhu_wait_for_outbox_records(
|
|
5563
|
+
conversation_id,
|
|
5564
|
+
delivered_count=delivered_count,
|
|
5565
|
+
timeout_seconds=self._lingzhu_wait_timeout_seconds(resolved),
|
|
5566
|
+
)
|
|
5567
|
+
emitted_after = self._lingzhu_emit_outbox_records(
|
|
5568
|
+
handler,
|
|
5569
|
+
message_id=message_id,
|
|
5570
|
+
agent_id=agent_id,
|
|
5571
|
+
records=pending_after,
|
|
5572
|
+
config=resolved,
|
|
5573
|
+
)
|
|
5574
|
+
if emitted_after:
|
|
5575
|
+
self._set_lingzhu_delivered_count(conversation_id, total_after)
|
|
5576
|
+
else:
|
|
5577
|
+
self._write_sse_event(
|
|
5578
|
+
handler,
|
|
5579
|
+
event="message",
|
|
5580
|
+
data=lingzhu_sse_answer(
|
|
5581
|
+
message_id=message_id,
|
|
5582
|
+
agent_id=agent_id,
|
|
5583
|
+
answer_stream="已开始" if emitted_before else self._lingzhu_short_status_text(target_quest_id),
|
|
5584
|
+
is_finish=True,
|
|
5585
|
+
),
|
|
5586
|
+
)
|
|
5587
|
+
handler.close_connection = True
|
|
5588
|
+
return
|
|
5589
|
+
|
|
5590
|
+
if known_quest_id:
|
|
5591
|
+
self.sessions.bind(known_quest_id, conversation_id)
|
|
5592
|
+
self.quest_service.bind_source(known_quest_id, binding_conversation_id)
|
|
5593
|
+
|
|
5594
|
+
pending_records, total_count = self._lingzhu_pending_outbox_records(
|
|
5595
|
+
conversation_id,
|
|
5596
|
+
delivered_count=delivered_count,
|
|
5597
|
+
)
|
|
5598
|
+
emitted = self._lingzhu_emit_outbox_records(
|
|
5599
|
+
handler,
|
|
5600
|
+
message_id=message_id,
|
|
5601
|
+
agent_id=agent_id,
|
|
5602
|
+
records=pending_records,
|
|
5603
|
+
config=resolved,
|
|
5604
|
+
)
|
|
5605
|
+
if emitted:
|
|
5606
|
+
self._set_lingzhu_delivered_count(conversation_id, total_count)
|
|
5607
|
+
else:
|
|
5608
|
+
self._write_sse_event(
|
|
5609
|
+
handler,
|
|
5610
|
+
event="message",
|
|
5611
|
+
data=lingzhu_sse_answer(
|
|
5612
|
+
message_id=message_id,
|
|
5613
|
+
agent_id=agent_id,
|
|
5614
|
+
answer_stream=self._lingzhu_short_status_text(known_quest_id),
|
|
5615
|
+
is_finish=True,
|
|
5616
|
+
),
|
|
5617
|
+
)
|
|
5618
|
+
handler.close_connection = True
|
|
5619
|
+
except (BrokenPipeError, ConnectionResetError, TimeoutError):
|
|
5620
|
+
return
|
|
5621
|
+
|
|
4266
5622
|
@staticmethod
|
|
4267
5623
|
def _write_sse_event(
|
|
4268
5624
|
handler: BaseHTTPRequestHandler,
|
|
@@ -4679,6 +6035,7 @@ class DaemonApp:
|
|
|
4679
6035
|
return
|
|
4680
6036
|
|
|
4681
6037
|
def serve(self, host: str, port: int) -> None:
|
|
6038
|
+
self._install_process_observability()
|
|
4682
6039
|
app = self
|
|
4683
6040
|
|
|
4684
6041
|
class RequestHandler(BaseHTTPRequestHandler):
|
|
@@ -4730,6 +6087,14 @@ class DaemonApp:
|
|
|
4730
6087
|
except Exception as exc:
|
|
4731
6088
|
self._write_json(500, {"ok": False, "message": str(exc)})
|
|
4732
6089
|
return
|
|
6090
|
+
if route_name == "lingzhu_sse":
|
|
6091
|
+
content_length = int(self.headers.get("Content-Length", "0"))
|
|
6092
|
+
raw_body = self.rfile.read(content_length) if content_length else b""
|
|
6093
|
+
try:
|
|
6094
|
+
app.stream_lingzhu_sse(self, raw_body=raw_body, headers=dict(self.headers.items()))
|
|
6095
|
+
except Exception as exc:
|
|
6096
|
+
self._write_json(500, {"ok": False, "message": str(exc)})
|
|
6097
|
+
return
|
|
4733
6098
|
|
|
4734
6099
|
content_length = int(self.headers.get("Content-Length", "0"))
|
|
4735
6100
|
raw_body = self.rfile.read(content_length) if content_length else b""
|
|
@@ -4765,11 +6130,14 @@ class DaemonApp:
|
|
|
4765
6130
|
"terminal_restore",
|
|
4766
6131
|
"terminal_history",
|
|
4767
6132
|
"latex_builds",
|
|
6133
|
+
"arxiv_list",
|
|
6134
|
+
"annotations_file",
|
|
6135
|
+
"annotations_project",
|
|
4768
6136
|
}:
|
|
4769
6137
|
payload = result(**params, path=self.path)
|
|
4770
6138
|
elif method == "GET":
|
|
4771
6139
|
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", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action"}:
|
|
6140
|
+
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
6141
|
payload = result(**params, body=body)
|
|
4774
6142
|
elif route_name == "config_validate":
|
|
4775
6143
|
payload = result(body)
|
|
@@ -4818,11 +6186,20 @@ class DaemonApp:
|
|
|
4818
6186
|
self._shutdown_requested.clear()
|
|
4819
6187
|
self._start_terminal_attach_server(host, port)
|
|
4820
6188
|
self._start_background_connectors()
|
|
6189
|
+
self._resume_reconciled_quests()
|
|
4821
6190
|
print(f"DeepScientist daemon listening on http://{host}:{port}")
|
|
4822
6191
|
try:
|
|
4823
6192
|
server.serve_forever()
|
|
4824
6193
|
except KeyboardInterrupt:
|
|
4825
|
-
|
|
6194
|
+
self.logger.log("warning", "daemon.keyboard_interrupt", pid=os.getpid())
|
|
6195
|
+
except BaseException as exc:
|
|
6196
|
+
self._log_unhandled_exception(
|
|
6197
|
+
event_type="daemon.serve_crashed",
|
|
6198
|
+
exc_type=type(exc),
|
|
6199
|
+
exc_value=exc,
|
|
6200
|
+
exc_traceback=exc.__traceback__,
|
|
6201
|
+
)
|
|
6202
|
+
raise
|
|
4826
6203
|
finally:
|
|
4827
6204
|
self._stop_background_connectors()
|
|
4828
6205
|
self._stop_terminal_attach_server()
|