@researai/deepscientist 1.5.13 → 1.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +8 -0
  2. package/assets/branding/logo-raster.png +0 -0
  3. package/bin/ds.js +134 -49
  4. package/docs/en/00_QUICK_START.md +2 -2
  5. package/docs/en/01_SETTINGS_REFERENCE.md +20 -4
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +19 -0
  7. package/docs/en/05_TUI_GUIDE.md +466 -96
  8. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  9. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  10. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  11. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  12. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  13. package/docs/en/README.md +8 -0
  14. package/docs/zh/00_QUICK_START.md +2 -2
  15. package/docs/zh/01_SETTINGS_REFERENCE.md +20 -4
  16. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +19 -0
  17. package/docs/zh/05_TUI_GUIDE.md +465 -82
  18. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +20 -0
  19. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +2 -0
  20. package/docs/zh/16_TELEGRAM_CONNECTOR_GUIDE.md +134 -0
  21. package/docs/zh/17_WHATSAPP_CONNECTOR_GUIDE.md +126 -0
  22. package/docs/zh/18_FEISHU_CONNECTOR_GUIDE.md +136 -0
  23. package/docs/zh/README.md +8 -0
  24. package/install.sh +2 -0
  25. package/package.json +1 -1
  26. package/pyproject.toml +1 -1
  27. package/src/deepscientist/__init__.py +1 -1
  28. package/src/deepscientist/artifact/charts.py +567 -0
  29. package/src/deepscientist/artifact/guidance.py +50 -10
  30. package/src/deepscientist/artifact/metrics.py +228 -5
  31. package/src/deepscientist/artifact/schemas.py +3 -0
  32. package/src/deepscientist/artifact/service.py +4004 -538
  33. package/src/deepscientist/bash_exec/models.py +23 -0
  34. package/src/deepscientist/bash_exec/monitor.py +147 -67
  35. package/src/deepscientist/bash_exec/runtime.py +218 -156
  36. package/src/deepscientist/bash_exec/service.py +79 -64
  37. package/src/deepscientist/bash_exec/shells.py +87 -0
  38. package/src/deepscientist/bridges/connectors.py +51 -2
  39. package/src/deepscientist/config/models.py +6 -3
  40. package/src/deepscientist/config/service.py +7 -2
  41. package/src/deepscientist/connector/lingzhu_support.py +23 -4
  42. package/src/deepscientist/connector/weixin_support.py +122 -1
  43. package/src/deepscientist/daemon/api/handlers.py +75 -4
  44. package/src/deepscientist/daemon/api/router.py +1 -0
  45. package/src/deepscientist/daemon/app.py +869 -236
  46. package/src/deepscientist/doctor.py +51 -0
  47. package/src/deepscientist/file_lock.py +48 -0
  48. package/src/deepscientist/gitops/diff.py +167 -1
  49. package/src/deepscientist/mcp/server.py +331 -21
  50. package/src/deepscientist/process_control.py +161 -0
  51. package/src/deepscientist/prompts/builder.py +275 -491
  52. package/src/deepscientist/quest/service.py +2336 -145
  53. package/src/deepscientist/quest/stage_views.py +305 -29
  54. package/src/deepscientist/runners/base.py +2 -0
  55. package/src/deepscientist/runners/codex.py +88 -5
  56. package/src/deepscientist/runners/runtime_overrides.py +17 -1
  57. package/src/deepscientist/shared.py +6 -1
  58. package/src/prompts/contracts/shared_interaction.md +13 -4
  59. package/src/prompts/system.md +984 -1985
  60. package/src/skills/analysis-campaign/SKILL.md +31 -2
  61. package/src/skills/analysis-campaign/references/artifact-orchestration.md +1 -1
  62. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +65 -0
  63. package/src/skills/baseline/SKILL.md +267 -994
  64. package/src/skills/baseline/references/baseline-checklist-template.md +21 -32
  65. package/src/skills/baseline/references/baseline-plan-template.md +41 -57
  66. package/src/skills/decision/SKILL.md +19 -2
  67. package/src/skills/experiment/SKILL.md +8 -2
  68. package/src/skills/finalize/SKILL.md +18 -0
  69. package/src/skills/idea/SKILL.md +78 -0
  70. package/src/skills/idea/references/idea-generation-playbook.md +100 -0
  71. package/src/skills/idea/references/outline-seeding-example.md +60 -0
  72. package/src/skills/intake-audit/SKILL.md +1 -1
  73. package/src/skills/optimize/SKILL.md +1644 -0
  74. package/src/skills/rebuttal/SKILL.md +2 -1
  75. package/src/skills/review/SKILL.md +2 -1
  76. package/src/skills/write/SKILL.md +80 -12
  77. package/src/skills/write/references/outline-evidence-contract-example.md +107 -0
  78. package/src/tui/dist/app/AppContainer.js +1445 -52
  79. package/src/tui/dist/components/Composer.js +1 -1
  80. package/src/tui/dist/components/ConfigScreen.js +190 -36
  81. package/src/tui/dist/components/GradientStatusText.js +1 -20
  82. package/src/tui/dist/components/InputPrompt.js +41 -32
  83. package/src/tui/dist/components/LoadingIndicator.js +1 -1
  84. package/src/tui/dist/components/Logo.js +61 -38
  85. package/src/tui/dist/components/MainContent.js +10 -3
  86. package/src/tui/dist/components/WelcomePanel.js +4 -12
  87. package/src/tui/dist/components/messages/AssistantMessage.js +1 -1
  88. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -3
  89. package/src/tui/dist/components/messages/OperationMessage.js +1 -1
  90. package/src/tui/dist/index.js +28 -1
  91. package/src/tui/dist/layouts/DefaultAppLayout.js +3 -3
  92. package/src/tui/dist/lib/api.js +17 -0
  93. package/src/tui/dist/lib/connectors.js +261 -0
  94. package/src/tui/dist/semantic-colors.js +29 -19
  95. package/src/tui/package.json +1 -1
  96. package/src/ui/dist/assets/{AiManusChatView-CnJcXynW.js → AiManusChatView-DDjbFnbt.js} +12 -12
  97. package/src/ui/dist/assets/{AnalysisPlugin-DeyzPEhV.js → AnalysisPlugin-Yb5IdmaU.js} +1 -1
  98. package/src/ui/dist/assets/CliPlugin-e64sreyu.js +31037 -0
  99. package/src/ui/dist/assets/{CodeEditorPlugin-B-xicq1e.js → CodeEditorPlugin-C4D2TIkU.js} +8 -8
  100. package/src/ui/dist/assets/{CodeViewerPlugin-DT54ysXa.js → CodeViewerPlugin-BVoNZIvC.js} +5 -5
  101. package/src/ui/dist/assets/{DocViewerPlugin-DQtKT-VD.js → DocViewerPlugin-CLChbllo.js} +3 -3
  102. package/src/ui/dist/assets/{GitDiffViewerPlugin-hqHbCfnv.js → GitDiffViewerPlugin-C4xeFyFQ.js} +20 -20
  103. package/src/ui/dist/assets/{ImageViewerPlugin-OcVo33jV.js → ImageViewerPlugin-OiMUAcLi.js} +5 -5
  104. package/src/ui/dist/assets/{LabCopilotPanel-DdGwhEUV.js → LabCopilotPanel-BjD2ThQF.js} +11 -11
  105. package/src/ui/dist/assets/{LabPlugin-Ciz1gDaX.js → LabPlugin-DQPg-NrB.js} +2 -2
  106. package/src/ui/dist/assets/{LatexPlugin-BhmjNQRC.js → LatexPlugin-CI05XAV9.js} +7 -7
  107. package/src/ui/dist/assets/{MarkdownViewerPlugin-BzdVH9Bx.js → MarkdownViewerPlugin-DpeBLYZf.js} +4 -4
  108. package/src/ui/dist/assets/{MarketplacePlugin-DmyHspXt.js → MarketplacePlugin-DolE58Q2.js} +3 -3
  109. package/src/ui/dist/assets/{NotebookEditor-BTVYRGkm.js → NotebookEditor-7Qm2rSWD.js} +11 -11
  110. package/src/ui/dist/assets/{NotebookEditor-BMXKrDRk.js → NotebookEditor-C1kWaxKi.js} +1 -1
  111. package/src/ui/dist/assets/{PdfLoader-CvcjJHXv.js → PdfLoader-BfOHw8Zw.js} +1 -1
  112. package/src/ui/dist/assets/{PdfMarkdownPlugin-DW2ej8Vk.js → PdfMarkdownPlugin-BulDREv1.js} +2 -2
  113. package/src/ui/dist/assets/{PdfViewerPlugin-CmlDxbhU.js → PdfViewerPlugin-C-daaOaL.js} +10 -10
  114. package/src/ui/dist/assets/{SearchPlugin-DAjQZPSv.js → SearchPlugin-CjpaiJ3A.js} +1 -1
  115. package/src/ui/dist/assets/{TextViewerPlugin-C-nVAZb_.js → TextViewerPlugin-BxIyqPQC.js} +5 -5
  116. package/src/ui/dist/assets/{VNCViewer-D7-dIYon.js → VNCViewer-HAg9mF7M.js} +10 -10
  117. package/src/ui/dist/assets/{bot-C_G4WtNI.js → bot-0DYntytV.js} +1 -1
  118. package/src/ui/dist/assets/{code-Cd7WfiWq.js → code-B20Slj_w.js} +1 -1
  119. package/src/ui/dist/assets/{file-content-B57zsL9y.js → file-content-DT24KFma.js} +1 -1
  120. package/src/ui/dist/assets/{file-diff-panel-DVoheLFq.js → file-diff-panel-DK13YPql.js} +1 -1
  121. package/src/ui/dist/assets/{file-socket-B5kXFxZP.js → file-socket-B4T2o4nR.js} +1 -1
  122. package/src/ui/dist/assets/{image-LLOjkMHF.js → image-DSeR_sDS.js} +1 -1
  123. package/src/ui/dist/assets/{index-hOUOWbW2.js → index-BrFje2Uk.js} +2 -2
  124. package/src/ui/dist/assets/{index-Dxa2eYMY.js → index-BwRJaoTl.js} +1 -1
  125. package/src/ui/dist/assets/{index-CLQauncb.js → index-D_E4281X.js} +5418 -28620
  126. package/src/ui/dist/assets/{index-C3r2iGrp.js → index-DnYB3xb1.js} +12 -12
  127. package/src/ui/dist/assets/{index-BQG-1s2o.css → index-G7AcWcMu.css} +43 -2
  128. package/src/ui/dist/assets/{monaco-BGGAEii3.js → monaco-LExaAN3Y.js} +1 -1
  129. package/src/ui/dist/assets/{pdf-effect-queue-DlEr1_y5.js → pdf-effect-queue-BJk5okWJ.js} +1 -1
  130. package/src/ui/dist/assets/{popover-CWJbJuYY.js → popover-D3Gg_FoV.js} +1 -1
  131. package/src/ui/dist/assets/{project-sync-CRJiucYO.js → project-sync-C_ygLlVU.js} +1 -1
  132. package/src/ui/dist/assets/{select-CoHB7pvH.js → select-CpAK6uWm.js} +2 -2
  133. package/src/ui/dist/assets/{sigma-D5aJWR8J.js → sigma-DEccaSgk.js} +1 -1
  134. package/src/ui/dist/assets/{square-check-big-DUK_mnkS.js → square-check-big-uUfyVsbD.js} +1 -1
  135. package/src/ui/dist/assets/{trash-ChU3SEE3.js → trash-CXvwwSe8.js} +1 -1
  136. package/src/ui/dist/assets/{useCliAccess-BrJBV3tY.js → useCliAccess-Bnop4mgR.js} +1 -1
  137. package/src/ui/dist/assets/{useFileDiffOverlay-C2OQaVWc.js → useFileDiffOverlay-B8eUAX0I.js} +1 -1
  138. package/src/ui/dist/assets/{wrap-text-C7Qqh-om.js → wrap-text-9vbOBpkW.js} +1 -1
  139. package/src/ui/dist/assets/{zoom-out-rtX0FKya.js → zoom-out-BgVMmOW4.js} +1 -1
  140. package/src/ui/dist/index.html +2 -2
  141. package/uv.lock +1 -1
  142. package/src/ui/dist/assets/CliPlugin-CB1YODQn.js +0 -5905
@@ -3,9 +3,29 @@ import fs from 'node:fs';
3
3
  import { useApp, useInput } from 'ink';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { client } from '../lib/api.js';
6
+ import { CONNECTOR_ORDER, connectorLabel, connectorSubtitle, createLingzhuAk, looksLikeWeixinQrImageUrl, resolveLingzhuAuthAk, supportsGuidedConnector, } from '../lib/connectorConfig.js';
7
+ import { connectorTargetLabel, normalizeConnectorTargets, qqProfileDisplayLabel, qqProfileStatus, selectQqProfileTarget, } from '../lib/connectors.js';
8
+ import { renderQrAscii } from '../lib/qr.js';
6
9
  import { buildToolOperationContent, extractToolSubject } from '../lib/toolOperations.js';
7
10
  import { DefaultAppLayout } from '../layouts/DefaultAppLayout.js';
8
11
  const LOCAL_USER_SOURCE = 'tui-local';
12
+ const CONFIG_ROOT_ENTRIES = [
13
+ {
14
+ id: 'connectors',
15
+ title: 'Connectors',
16
+ description: 'Choose QQ, Weixin, Lingzhu, or another connector and configure it with arrows plus Enter.',
17
+ },
18
+ {
19
+ id: 'global-files',
20
+ title: 'Global Config Files',
21
+ description: 'Open raw runtime config files such as config.yaml, runners.yaml, and connectors.yaml.',
22
+ },
23
+ {
24
+ id: 'quest-files',
25
+ title: 'Current Quest Files',
26
+ description: 'Open quest-local config files for the currently selected quest.',
27
+ },
28
+ ];
9
29
  const buildId = (prefix, raw) => `${prefix}:${raw}`;
10
30
  const stringifyStructured = (value) => {
11
31
  if (typeof value === 'string') {
@@ -107,6 +127,130 @@ const resolveConfigTarget = (token, items) => {
107
127
  }
108
128
  return (items.find((item) => item.name === trimmed || item.title === trimmed || item.configName === trimmed) ?? null);
109
129
  };
130
+ const resolveManagedConnectorName = (token) => {
131
+ const normalized = String(token || '').trim().toLowerCase();
132
+ if (!normalized) {
133
+ return null;
134
+ }
135
+ if (CONNECTOR_ORDER.includes(normalized)) {
136
+ return normalized;
137
+ }
138
+ if (normalized === 'wechat') {
139
+ return 'weixin';
140
+ }
141
+ return null;
142
+ };
143
+ const connectorNamesFromStructuredConfig = (structured) => {
144
+ const known = new Set();
145
+ for (const name of CONNECTOR_ORDER) {
146
+ if (structured && typeof structured[name] === 'object') {
147
+ known.add(name);
148
+ }
149
+ }
150
+ return CONNECTOR_ORDER.filter((name) => known.has(name));
151
+ };
152
+ const asRecord = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
153
+ const cloneStructured = (value) => {
154
+ try {
155
+ return JSON.parse(JSON.stringify(value));
156
+ }
157
+ catch {
158
+ return { ...value };
159
+ }
160
+ };
161
+ const displayBoolean = (value) => (Boolean(value) ? 'on' : 'off');
162
+ const displayMaybeString = (value) => {
163
+ const text = String(value ?? '').trim();
164
+ return text || '—';
165
+ };
166
+ const CONNECTOR_BIND_ACTION_PREFIX = 'bind-target:';
167
+ const CONNECTOR_UNBIND_ACTION_PREFIX = 'unbind-target:';
168
+ const LINGZHU_PUBLIC_AGENT_ID = 'DeepScientist';
169
+ const LINGZHU_PLATFORM_AGENT_NAME = 'DeepScientist';
170
+ const LINGZHU_PLATFORM_CATEGORY = 'Work';
171
+ const LINGZHU_PLATFORM_INPUT_TYPE = 'Text';
172
+ const LINGZHU_PLATFORM_CAPABILITY_SUMMARY = 'DeepScientist is a local-first research agent for planning, experiments, analysis, writing, and execution follow-up.';
173
+ const LINGZHU_PLATFORM_OPENING_MESSAGE = 'Hello, I am DeepScientist. Tell me the research goal, experiment question, or task you want to move forward.';
174
+ const LINGZHU_PLATFORM_LOGO_PATH = '/assets/branding/logo-rokid.png';
175
+ const normalizedText = (value) => String(value ?? '').trim();
176
+ const normalizeBaseUrl = (value) => {
177
+ const text = normalizedText(value);
178
+ return text ? text.replace(/\/+$/, '') : '';
179
+ };
180
+ const looksLikePublicBaseUrl = (value) => {
181
+ const text = normalizeBaseUrl(value);
182
+ if (!text) {
183
+ return false;
184
+ }
185
+ try {
186
+ const url = new URL(text);
187
+ if (!['http:', 'https:'].includes(url.protocol)) {
188
+ return false;
189
+ }
190
+ const host = url.hostname.trim().toLowerCase();
191
+ if (!host || ['localhost', '0.0.0.0', '127.0.0.1', '::1'].includes(host) || host.endsWith('.local')) {
192
+ return false;
193
+ }
194
+ if (/^10\./.test(host) || /^192\.168\./.test(host) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(host)) {
195
+ return false;
196
+ }
197
+ return true;
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ };
203
+ const lingzhuPublicSseUrl = (config, details) => {
204
+ const detailValue = normalizeBaseUrl(details.public_endpoint_url);
205
+ if (detailValue) {
206
+ return detailValue;
207
+ }
208
+ const base = normalizeBaseUrl(config.public_base_url);
209
+ return base ? `${base}/metis/agent/api/sse` : '';
210
+ };
211
+ const lingzhuPublicHealthUrl = (config, details) => {
212
+ const detailValue = normalizeBaseUrl(details.public_health_url);
213
+ if (detailValue) {
214
+ return detailValue;
215
+ }
216
+ const base = normalizeBaseUrl(config.public_base_url);
217
+ return base ? `${base}/metis/agent/api/health` : '';
218
+ };
219
+ const lingzhuPlatformLogoUrl = (baseUrl) => {
220
+ try {
221
+ const url = new URL(baseUrl);
222
+ if (url.hostname === '0.0.0.0') {
223
+ url.hostname = '127.0.0.1';
224
+ }
225
+ return new URL(LINGZHU_PLATFORM_LOGO_PATH, url.origin).toString();
226
+ }
227
+ catch {
228
+ return LINGZHU_PLATFORM_LOGO_PATH;
229
+ }
230
+ };
231
+ const buildConnectorBindActionId = (connectorName, conversationId) => `${CONNECTOR_BIND_ACTION_PREFIX}${connectorName}:${encodeURIComponent(conversationId)}`;
232
+ const parseConnectorBindActionId = (actionId) => {
233
+ if (!actionId.startsWith(CONNECTOR_BIND_ACTION_PREFIX)) {
234
+ return null;
235
+ }
236
+ const payload = actionId.slice(CONNECTOR_BIND_ACTION_PREFIX.length);
237
+ const separator = payload.indexOf(':');
238
+ if (separator < 0) {
239
+ return null;
240
+ }
241
+ return {
242
+ connectorName: payload.slice(0, separator),
243
+ conversationId: decodeURIComponent(payload.slice(separator + 1)),
244
+ };
245
+ };
246
+ const buildConnectorUnbindActionId = (connectorName) => `${CONNECTOR_UNBIND_ACTION_PREFIX}${connectorName}`;
247
+ const parseConnectorUnbindActionId = (actionId) => {
248
+ if (!actionId.startsWith(CONNECTOR_UNBIND_ACTION_PREFIX)) {
249
+ return null;
250
+ }
251
+ const connectorName = actionId.slice(CONNECTOR_UNBIND_ACTION_PREFIX.length).trim();
252
+ return connectorName ? connectorName : null;
253
+ };
110
254
  function normalizeUpdate(raw) {
111
255
  const eventType = String(raw.event_type ?? '');
112
256
  const data = (raw.data ?? {});
@@ -363,10 +507,15 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
363
507
  const [connectors, setConnectors] = useState([]);
364
508
  const [activeQuestId, setActiveQuestId] = useState(initialQuestId);
365
509
  const [browseQuestId, setBrowseQuestId] = useState(initialQuestId);
366
- const [configMode, setConfigMode] = useState(null);
510
+ const [configView, setConfigView] = useState(null);
367
511
  const [configItems, setConfigItems] = useState([]);
368
512
  const [configIndex, setConfigIndex] = useState(0);
513
+ const [configSectionTitle, setConfigSectionTitle] = useState('Config');
514
+ const [configSectionDescription, setConfigSectionDescription] = useState('');
369
515
  const [configEditor, setConfigEditor] = useState(null);
516
+ const [connectorsDocument, setConnectorsDocument] = useState(null);
517
+ const [selectedConnectorName, setSelectedConnectorName] = useState(null);
518
+ const [weixinQrState, setWeixinQrState] = useState(null);
370
519
  const [questPanelMode, setQuestPanelMode] = useState(null);
371
520
  const [questPanelIndex, setQuestPanelIndex] = useState(0);
372
521
  const [session, setSession] = useState(null);
@@ -387,7 +536,631 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
387
536
  const activeQuest = useMemo(() => quests.find((quest) => quest.quest_id === activeQuestId) ?? null, [quests, activeQuestId]);
388
537
  const browseQuest = useMemo(() => quests.find((quest) => quest.quest_id === browseQuestId) ?? null, [quests, browseQuestId]);
389
538
  const panelQuests = useMemo(() => (questPanelMode ? getPanelQuests(questPanelMode, quests) : []), [questPanelMode, quests]);
539
+ const configMode = useMemo(() => (configView ? (configEditor ? 'edit' : 'browse') : null), [configEditor, configView]);
390
540
  const selectedQuestForConfig = useMemo(() => activeQuest ?? browseQuest ?? null, [activeQuest, browseQuest]);
541
+ const selectedConnectorSnapshot = useMemo(() => selectedConnectorName
542
+ ? connectors.find((item) => String(item.name || '').trim().toLowerCase() === selectedConnectorName) ?? null
543
+ : null, [connectors, selectedConnectorName]);
544
+ const selectedConnectorConfig = useMemo(() => (selectedConnectorName ? asRecord(connectorsDocument?.structured?.[selectedConnectorName]) : {}), [connectorsDocument?.structured, selectedConnectorName]);
545
+ const connectorMenuEntries = useMemo(() => {
546
+ const configuredNames = connectorNamesFromStructuredConfig(connectorsDocument?.structured ?? null);
547
+ const names = configuredNames.length > 0 ? configuredNames : CONNECTOR_ORDER;
548
+ return names.map((name) => {
549
+ const snapshot = connectors.find((item) => String(item.name || '').trim().toLowerCase() === name) ?? null;
550
+ return {
551
+ name,
552
+ label: connectorLabel(name),
553
+ subtitle: connectorSubtitle(name),
554
+ enabled: snapshot?.enabled !== false && Boolean(snapshot?.enabled || asRecord(connectorsDocument?.structured?.[name]).enabled),
555
+ connectionState: snapshot?.connection_state || snapshot?.auth_state || 'idle',
556
+ bindingCount: snapshot?.binding_count,
557
+ targetCount: snapshot?.target_count,
558
+ supportMode: supportsGuidedConnector(name) ? 'guided' : 'raw',
559
+ };
560
+ });
561
+ }, [connectors, connectorsDocument?.structured]);
562
+ const qqHasMultipleProfiles = useMemo(() => {
563
+ const profiles = selectedConnectorName === 'qq' ? selectedConnectorConfig.profiles : null;
564
+ return Array.isArray(profiles) && profiles.length > 1;
565
+ }, [selectedConnectorConfig, selectedConnectorName]);
566
+ const selectedConnectorTargets = useMemo(() => (selectedConnectorSnapshot ? normalizeConnectorTargets(selectedConnectorSnapshot) : []), [selectedConnectorSnapshot]);
567
+ const qqProfileSummaries = useMemo(() => {
568
+ if (selectedConnectorName !== 'qq') {
569
+ return [];
570
+ }
571
+ const configuredProfiles = Array.isArray(selectedConnectorConfig.profiles)
572
+ ? selectedConnectorConfig.profiles
573
+ .filter((item) => Boolean(item) && typeof item === 'object' && !Array.isArray(item))
574
+ .map((item) => asRecord(item))
575
+ : [];
576
+ const runtimeProfiles = Array.isArray(selectedConnectorSnapshot?.profiles) ? selectedConnectorSnapshot.profiles : [];
577
+ const runtimeById = new Map(runtimeProfiles.map((profile) => [normalizedText(profile.profile_id), profile]));
578
+ const totalProfiles = configuredProfiles.length;
579
+ return configuredProfiles.map((profile, index) => {
580
+ const profileId = normalizedText(profile.profile_id || `qq-profile-${index + 1}`);
581
+ const runtime = runtimeById.get(profileId);
582
+ const profileTargets = selectedConnectorTargets.filter((target) => {
583
+ const targetProfileId = normalizedText(target.profile_id);
584
+ if (targetProfileId) {
585
+ return targetProfileId === profileId;
586
+ }
587
+ return totalProfiles === 1;
588
+ });
589
+ const mainChatId = normalizedText(profile.main_chat_id || runtime?.main_chat_id);
590
+ const selectedTarget = selectQqProfileTarget(profileTargets, mainChatId);
591
+ return {
592
+ profileId,
593
+ label: qqProfileDisplayLabel({
594
+ profile_id: profileId,
595
+ bot_name: normalizedText(profile.bot_name) || undefined,
596
+ app_id: normalizedText(profile.app_id) || undefined,
597
+ }, runtime ?? null),
598
+ appId: normalizedText(profile.app_id || runtime?.app_id),
599
+ mainChatId,
600
+ lastConversationId: normalizedText(runtime?.last_conversation_id),
601
+ targetCount: profileTargets.length,
602
+ selectedTarget,
603
+ status: qqProfileStatus(runtime ?? null, profileTargets, mainChatId),
604
+ };
605
+ });
606
+ }, [selectedConnectorConfig.profiles, selectedConnectorName, selectedConnectorSnapshot?.profiles, selectedConnectorTargets]);
607
+ const connectorContextLine = useMemo(() => {
608
+ if (selectedQuestForConfig?.quest_id) {
609
+ return `Current quest for binding: ${selectedQuestForConfig.quest_id}`;
610
+ }
611
+ return 'No active quest selected yet. You can still save connector settings.';
612
+ }, [selectedQuestForConfig?.quest_id]);
613
+ const connectorGuideSections = useMemo(() => {
614
+ if (!selectedConnectorName) {
615
+ return [];
616
+ }
617
+ if (selectedConnectorName === 'weixin') {
618
+ const accountId = normalizedText(selectedConnectorConfig.account_id || selectedConnectorSnapshot?.details?.account_id);
619
+ const loginUserId = normalizedText(selectedConnectorConfig.login_user_id);
620
+ const hasBinding = Boolean(accountId);
621
+ return [
622
+ {
623
+ id: 'weixin-flow',
624
+ title: 'Top Guide',
625
+ tone: hasBinding ? 'success' : 'info',
626
+ lines: [
627
+ '1. Choose Bind/Rebind Weixin to create a QR code inside TUI.',
628
+ '2. Scan the QR code with the WeChat app on the phone that owns this account, then confirm the login in WeChat.',
629
+ '3. DeepScientist saves the binding automatically and returns here after confirmation.',
630
+ ],
631
+ },
632
+ {
633
+ id: 'weixin-status',
634
+ title: 'Current Status',
635
+ tone: hasBinding ? 'success' : 'warning',
636
+ lines: [
637
+ `Bot account: ${accountId || 'not bound yet'}`,
638
+ `Owner account: ${loginUserId || 'not bound yet'}`,
639
+ `Known targets: ${selectedConnectorTargets.length || selectedConnectorSnapshot?.target_count || 0}`,
640
+ ],
641
+ },
642
+ ];
643
+ }
644
+ if (selectedConnectorName === 'lingzhu') {
645
+ const details = asRecord(selectedConnectorSnapshot?.details);
646
+ const authAk = normalizedText(resolveLingzhuAuthAk(selectedConnectorConfig.auth_ak));
647
+ const publicBaseUrl = normalizeBaseUrl(selectedConnectorConfig.public_base_url);
648
+ const publicSseUrl = normalizedText(lingzhuPublicSseUrl(selectedConnectorConfig, details));
649
+ const publicReady = looksLikePublicBaseUrl(publicBaseUrl);
650
+ const tone = publicReady && authAk ? 'success' : 'warning';
651
+ return [
652
+ {
653
+ id: 'lingzhu-flow',
654
+ title: 'Top Guide',
655
+ tone,
656
+ lines: [
657
+ '1. Set Public base URL to the final public DeepScientist origin before binding a real Rokid device.',
658
+ '2. Copy the generated Rokid fields below into the platform exactly as shown, including Custom agent ID, URL, AK, Agent name, Category, Capability summary, Opening message, and Input type.',
659
+ '3. After the Rokid form is filled on the platform, return here and run Save Connector.',
660
+ ],
661
+ },
662
+ {
663
+ id: 'lingzhu-platform',
664
+ title: 'Generated For Rokid',
665
+ tone,
666
+ lines: [
667
+ `Custom agent ID: ${normalizedText(selectedConnectorConfig.agent_id || LINGZHU_PUBLIC_AGENT_ID) || LINGZHU_PUBLIC_AGENT_ID}`,
668
+ `Custom agent URL: ${publicSseUrl || 'set Public base URL first'}`,
669
+ `Custom agent AK: ${authAk || 'generate or fill Custom agent AK first'}`,
670
+ `Agent name: ${LINGZHU_PLATFORM_AGENT_NAME}`,
671
+ `Category: ${LINGZHU_PLATFORM_CATEGORY}`,
672
+ `Capability summary: ${LINGZHU_PLATFORM_CAPABILITY_SUMMARY}`,
673
+ `Opening message: ${LINGZHU_PLATFORM_OPENING_MESSAGE}`,
674
+ `Input type: ${LINGZHU_PLATFORM_INPUT_TYPE}`,
675
+ ],
676
+ },
677
+ ...(publicReady
678
+ ? []
679
+ : [
680
+ {
681
+ id: 'lingzhu-warning',
682
+ title: 'Public URL Required',
683
+ tone: 'warning',
684
+ lines: ['The current Public base URL is not a public HTTP(S) address yet, so Rokid devices will not be able to reach this bridge.'],
685
+ },
686
+ ]),
687
+ ];
688
+ }
689
+ if (selectedConnectorName === 'qq') {
690
+ const detectedOpenId = normalizedText(selectedConnectorConfig.main_chat_id || selectedConnectorSnapshot?.main_chat_id);
691
+ const lastConversationId = normalizedText(selectedConnectorSnapshot?.last_conversation_id);
692
+ const hasCredentials = Boolean(normalizedText(selectedConnectorConfig.app_id) && normalizedText(selectedConnectorConfig.app_secret));
693
+ const hasDetectedTarget = Boolean(detectedOpenId || selectedConnectorTargets.length);
694
+ const tone = hasDetectedTarget ? 'success' : hasCredentials ? 'warning' : 'info';
695
+ return [
696
+ {
697
+ id: 'qq-flow',
698
+ title: 'Top Guide',
699
+ tone,
700
+ lines: [
701
+ '1. Create the bot in the official QQ Bot Platform and copy App ID plus App Secret.',
702
+ qqHasMultipleProfiles
703
+ ? '2. Multiple QQ profiles are configured. The TUI shows each profile status below, and you can still bind runtime targets here. Use raw connectors.yaml only for adding, deleting, or replacing profile credentials.'
704
+ : '2. Fill Bot name, App ID, and App Secret below, then run Save Connector.',
705
+ hasDetectedTarget
706
+ ? '3. OpenID and conversation targets are already visible below. Bind the current quest to the correct target.'
707
+ : '3. After saving, send one private QQ message to the bot. TUI auto-refreshes Detected OpenID and conversation_id from runtime activity.',
708
+ ],
709
+ },
710
+ {
711
+ id: 'qq-status',
712
+ title: 'Current Status',
713
+ tone,
714
+ lines: [
715
+ `Detected OpenID: ${detectedOpenId || 'waiting for first private message'}`,
716
+ `Last conversation: ${lastConversationId || 'waiting for runtime activity'}`,
717
+ `Configured profiles: ${qqProfileSummaries.length || 0}`,
718
+ `Discovered targets: ${selectedConnectorTargets.length}`,
719
+ ],
720
+ },
721
+ ...(!selectedQuestForConfig?.quest_id && hasDetectedTarget
722
+ ? [
723
+ {
724
+ id: 'qq-bind-warning',
725
+ title: 'Quest Binding Needs A Quest',
726
+ tone: 'warning',
727
+ lines: ['Open a quest with `/use <quest_id>` first, then come back here to bind the detected QQ target to that quest.'],
728
+ },
729
+ ]
730
+ : []),
731
+ ];
732
+ }
733
+ return [];
734
+ }, [
735
+ qqProfileSummaries.length,
736
+ qqHasMultipleProfiles,
737
+ selectedConnectorConfig,
738
+ selectedConnectorName,
739
+ selectedConnectorSnapshot,
740
+ selectedConnectorTargets,
741
+ selectedQuestForConfig?.quest_id,
742
+ ]);
743
+ const connectorDetailItems = useMemo(() => {
744
+ if (!selectedConnectorName) {
745
+ return [];
746
+ }
747
+ const activeBindingQuestId = selectedQuestForConfig?.quest_id || null;
748
+ const currentQuestTarget = activeBindingQuestId
749
+ ? selectedConnectorTargets.find((item) => normalizedText(item.bound_quest_id) === activeBindingQuestId) || null
750
+ : null;
751
+ const bindingActionItems = [];
752
+ if (activeBindingQuestId && currentQuestTarget) {
753
+ bindingActionItems.push({
754
+ type: 'action',
755
+ id: buildConnectorUnbindActionId(selectedConnectorName),
756
+ label: `Unbind ${activeBindingQuestId}`,
757
+ description: `Remove ${activeBindingQuestId} from ${connectorLabel(selectedConnectorName)} and return the quest to local-only binding.`,
758
+ });
759
+ }
760
+ if (activeBindingQuestId) {
761
+ for (const target of selectedConnectorTargets) {
762
+ const targetLabel = connectorTargetLabel(target) || target.conversation_id;
763
+ const bindingState = normalizedText(target.bound_quest_id) === activeBindingQuestId
764
+ ? `Already bound to ${activeBindingQuestId}.`
765
+ : normalizedText(target.bound_quest_id)
766
+ ? `Currently bound to ${target.bound_quest_id}. Enter will rebind it to ${activeBindingQuestId}.`
767
+ : `Not bound yet. Enter will bind it to ${activeBindingQuestId}.`;
768
+ bindingActionItems.push({
769
+ type: 'action',
770
+ id: buildConnectorBindActionId(selectedConnectorName, target.conversation_id),
771
+ label: `Bind ${targetLabel}`,
772
+ description: `${target.conversation_id} · ${bindingState}`,
773
+ });
774
+ }
775
+ }
776
+ if (selectedConnectorName === 'weixin') {
777
+ return [
778
+ {
779
+ type: 'action',
780
+ id: 'weixin-start-login',
781
+ label: selectedConnectorSnapshot?.enabled ? 'Rebind Weixin' : 'Bind Weixin',
782
+ description: 'Start QR login, render a QR code in TUI, and save the connector automatically after confirmation.',
783
+ },
784
+ {
785
+ type: 'action',
786
+ id: 'refresh-connector',
787
+ label: 'Refresh Status',
788
+ description: 'Reload connector runtime snapshot and current structured config.',
789
+ },
790
+ {
791
+ type: 'info',
792
+ id: 'weixin-account-id',
793
+ label: 'Bot account',
794
+ value: displayMaybeString(selectedConnectorConfig.account_id ?? selectedConnectorSnapshot?.details?.account_id),
795
+ description: 'Saved Weixin bot account id.',
796
+ },
797
+ {
798
+ type: 'info',
799
+ id: 'weixin-login-user',
800
+ label: 'Owner account',
801
+ value: displayMaybeString(selectedConnectorConfig.login_user_id),
802
+ description: 'WeChat account that confirmed the QR login.',
803
+ },
804
+ {
805
+ type: 'info',
806
+ id: 'weixin-base-url',
807
+ label: 'Base URL',
808
+ value: displayMaybeString(selectedConnectorConfig.base_url),
809
+ description: 'The iLink API base URL saved in connectors config.',
810
+ },
811
+ {
812
+ type: 'info',
813
+ id: 'weixin-targets',
814
+ label: 'Known targets',
815
+ value: String(selectedConnectorTargets.length || selectedConnectorSnapshot?.target_count || 0),
816
+ description: 'Targets discovered by the Weixin connector.',
817
+ },
818
+ ...bindingActionItems,
819
+ ];
820
+ }
821
+ if (selectedConnectorName === 'lingzhu') {
822
+ const details = asRecord(selectedConnectorSnapshot?.details);
823
+ const authAk = resolveLingzhuAuthAk(selectedConnectorConfig.auth_ak);
824
+ const publicReady = looksLikePublicBaseUrl(selectedConnectorConfig.public_base_url);
825
+ const publicSseUrl = lingzhuPublicSseUrl(selectedConnectorConfig, details);
826
+ const publicHealthUrl = lingzhuPublicHealthUrl(selectedConnectorConfig, details);
827
+ const saveReady = publicReady && Boolean(normalizedText(publicSseUrl)) && Boolean(normalizedText(authAk));
828
+ const generatedAgentId = normalizedText(selectedConnectorConfig.agent_id || LINGZHU_PUBLIC_AGENT_ID) || LINGZHU_PUBLIC_AGENT_ID;
829
+ const logoUrl = lingzhuPlatformLogoUrl(baseUrl);
830
+ return [
831
+ {
832
+ type: 'action',
833
+ id: 'save-connector',
834
+ label: 'Save Connector',
835
+ description: saveReady
836
+ ? 'Persist the generated Lingzhu values into connectors.yaml and reload the runtime.'
837
+ : 'Set a public base URL and a Custom agent AK first. The Web Rokid popup applies the same save gate.',
838
+ disabled: !saveReady,
839
+ },
840
+ {
841
+ type: 'action',
842
+ id: 'generate-lingzhu-ak',
843
+ label: 'Generate AK',
844
+ description: 'Create a new random Custom agent AK for the Lingzhu bridge.',
845
+ },
846
+ {
847
+ type: 'action',
848
+ id: 'refresh-connector',
849
+ label: 'Refresh Status',
850
+ description: 'Reload connector runtime snapshot and current structured config.',
851
+ },
852
+ {
853
+ type: 'field',
854
+ key: 'public_base_url',
855
+ label: 'Public base URL',
856
+ value: displayMaybeString(selectedConnectorConfig.public_base_url),
857
+ description: 'Publicly reachable DeepScientist base URL used by Rokid devices.',
858
+ fieldKind: 'url',
859
+ editable: true,
860
+ },
861
+ {
862
+ type: 'field',
863
+ key: 'local_host',
864
+ label: 'Local host',
865
+ value: displayMaybeString(selectedConnectorConfig.local_host),
866
+ description: 'Host used by DeepScientist when probing its own Lingzhu routes locally.',
867
+ fieldKind: 'text',
868
+ editable: true,
869
+ },
870
+ {
871
+ type: 'field',
872
+ key: 'gateway_port',
873
+ label: 'Gateway port',
874
+ value: displayMaybeString(selectedConnectorConfig.gateway_port),
875
+ description: 'Port used when building the health and SSE endpoints.',
876
+ fieldKind: 'text',
877
+ editable: true,
878
+ },
879
+ {
880
+ type: 'field',
881
+ key: 'agent_id',
882
+ label: 'Custom agent ID',
883
+ value: displayMaybeString(selectedConnectorConfig.agent_id || LINGZHU_PUBLIC_AGENT_ID),
884
+ description: 'Public agent id that must match the Rokid custom agent ID field.',
885
+ fieldKind: 'text',
886
+ editable: true,
887
+ },
888
+ {
889
+ type: 'field',
890
+ key: 'auth_ak',
891
+ label: 'Custom agent AK',
892
+ value: displayMaybeString(authAk),
893
+ description: 'Generated token that must match the AK pasted into the Rokid custom agent form.',
894
+ fieldKind: 'password',
895
+ editable: true,
896
+ },
897
+ {
898
+ type: 'info',
899
+ id: 'lingzhu-platform-agent-id',
900
+ label: 'Custom agent ID',
901
+ value: displayMaybeString(generatedAgentId),
902
+ description: 'Paste this exact value into the Rokid custom agent ID field.',
903
+ },
904
+ {
905
+ type: 'info',
906
+ id: 'lingzhu-platform-agent-url',
907
+ label: 'Custom agent URL',
908
+ value: displayMaybeString(publicSseUrl),
909
+ description: 'Paste this exact value into the Rokid custom agent URL field.',
910
+ },
911
+ {
912
+ type: 'info',
913
+ id: 'lingzhu-platform-agent-ak',
914
+ label: 'Custom agent AK',
915
+ value: displayMaybeString(authAk),
916
+ description: 'Paste this exact value into the Rokid custom agent AK field.',
917
+ },
918
+ {
919
+ type: 'info',
920
+ id: 'lingzhu-platform-agent-name',
921
+ label: 'Agent name',
922
+ value: LINGZHU_PLATFORM_AGENT_NAME,
923
+ description: 'Suggested display name for the Rokid custom agent form.',
924
+ },
925
+ {
926
+ type: 'info',
927
+ id: 'lingzhu-platform-category',
928
+ label: 'Category',
929
+ value: LINGZHU_PLATFORM_CATEGORY,
930
+ description: 'Recommended Rokid category for the DeepScientist agent.',
931
+ },
932
+ {
933
+ type: 'info',
934
+ id: 'lingzhu-platform-capability-summary',
935
+ label: 'Capability summary',
936
+ value: LINGZHU_PLATFORM_CAPABILITY_SUMMARY,
937
+ description: 'Paste this capability summary into the Rokid custom agent form.',
938
+ multiline: true,
939
+ },
940
+ {
941
+ type: 'info',
942
+ id: 'lingzhu-platform-opening-message',
943
+ label: 'Opening message',
944
+ value: LINGZHU_PLATFORM_OPENING_MESSAGE,
945
+ description: 'Paste this greeting into the Rokid opening message field.',
946
+ multiline: true,
947
+ },
948
+ {
949
+ type: 'info',
950
+ id: 'lingzhu-platform-input-type',
951
+ label: 'Input type',
952
+ value: LINGZHU_PLATFORM_INPUT_TYPE,
953
+ description: 'Recommended Rokid input type for DeepScientist.',
954
+ },
955
+ {
956
+ type: 'info',
957
+ id: 'lingzhu-platform-logo-url',
958
+ label: 'Icon/logo URL',
959
+ value: displayMaybeString(logoUrl),
960
+ description: 'Optional Rokid icon URL. The Web popup shows the same DeepScientist Rokid logo.',
961
+ },
962
+ {
963
+ type: 'info',
964
+ id: 'lingzhu-public-health',
965
+ label: 'Public health URL',
966
+ value: displayMaybeString(publicHealthUrl),
967
+ description: 'Public health endpoint for debugging.',
968
+ },
969
+ {
970
+ type: 'info',
971
+ id: 'lingzhu-local-sse',
972
+ label: 'Local SSE URL',
973
+ value: displayMaybeString(details.endpoint_url),
974
+ description: 'Local SSE endpoint used by DeepScientist probes.',
975
+ },
976
+ {
977
+ type: 'info',
978
+ id: 'lingzhu-local-health',
979
+ label: 'Local health URL',
980
+ value: displayMaybeString(details.health_url),
981
+ description: 'Local health endpoint used by DeepScientist probes.',
982
+ },
983
+ ...bindingActionItems,
984
+ ];
985
+ }
986
+ if (selectedConnectorName === 'qq') {
987
+ const secretValue = String(selectedConnectorConfig.app_secret ?? '').trim();
988
+ const qqProfileInfoItems = qqProfileSummaries.map((profile) => {
989
+ const statusText = profile.status === 'bound'
990
+ ? 'Bound'
991
+ : profile.status === 'ready'
992
+ ? 'Ready for binding'
993
+ : 'Waiting for first message';
994
+ const targetLabel = profile.selectedTarget ? connectorTargetLabel(profile.selectedTarget) || profile.selectedTarget.conversation_id : 'waiting';
995
+ const boundQuestId = normalizedText(profile.selectedTarget?.bound_quest_id) || 'not bound yet';
996
+ return {
997
+ type: 'info',
998
+ id: `qq-profile:${profile.profileId}`,
999
+ label: `Profile · ${profile.label}`,
1000
+ value: [
1001
+ `Profile ID: ${profile.profileId}`,
1002
+ `App ID: ${profile.appId || '—'}`,
1003
+ `Detected OpenID: ${profile.mainChatId || 'waiting for first private message'}`,
1004
+ `Last conversation: ${profile.lastConversationId || 'waiting for runtime activity'}`,
1005
+ `Targets: ${profile.targetCount}`,
1006
+ `Preferred target: ${targetLabel}`,
1007
+ `Bound quest: ${boundQuestId}`,
1008
+ `Status: ${statusText}`,
1009
+ ].join('\n'),
1010
+ description: 'Profile-level runtime summary aligned with the Web QQ connector cards.',
1011
+ multiline: true,
1012
+ };
1013
+ });
1014
+ return [
1015
+ {
1016
+ type: 'action',
1017
+ id: 'save-connector',
1018
+ label: 'Save Connector',
1019
+ description: qqHasMultipleProfiles
1020
+ ? 'Persist shared QQ settings and reload the runtime. Use raw connectors.yaml for profile add, delete, or credential replacement.'
1021
+ : 'Persist the current QQ fields into connectors.yaml and reload the runtime.',
1022
+ disabled: false,
1023
+ },
1024
+ {
1025
+ type: 'action',
1026
+ id: 'refresh-connector',
1027
+ label: 'Refresh Status',
1028
+ description: 'Reload connector runtime snapshot and current structured config.',
1029
+ },
1030
+ {
1031
+ type: 'action',
1032
+ id: 'open-raw-connectors',
1033
+ label: 'Open Raw connectors.yaml',
1034
+ description: 'Open the full connectors config file for advanced or multi-profile QQ changes.',
1035
+ },
1036
+ {
1037
+ type: 'field',
1038
+ key: 'bot_name',
1039
+ label: 'Bot name',
1040
+ value: displayMaybeString(selectedConnectorConfig.bot_name),
1041
+ description: qqHasMultipleProfiles
1042
+ ? 'Default display name for QQ profiles. Add or replace per-profile bot names in raw connectors.yaml.'
1043
+ : 'Display name used by the QQ connector.',
1044
+ fieldKind: 'text',
1045
+ editable: !qqHasMultipleProfiles,
1046
+ },
1047
+ {
1048
+ type: 'field',
1049
+ key: 'app_id',
1050
+ label: 'App ID',
1051
+ value: displayMaybeString(selectedConnectorConfig.app_id),
1052
+ description: 'Tencent QQ bot App ID.',
1053
+ fieldKind: 'text',
1054
+ editable: !qqHasMultipleProfiles,
1055
+ },
1056
+ {
1057
+ type: 'field',
1058
+ key: 'app_secret',
1059
+ label: 'App secret',
1060
+ value: displayMaybeString(secretValue),
1061
+ description: 'QQ bot App Secret. Save this first, then send one private QQ message to the bot.',
1062
+ fieldKind: 'password',
1063
+ editable: !qqHasMultipleProfiles,
1064
+ },
1065
+ {
1066
+ type: 'field',
1067
+ key: 'command_prefix',
1068
+ label: 'Command prefix',
1069
+ value: displayMaybeString(selectedConnectorConfig.command_prefix),
1070
+ description: 'Prefix used for slash-style QQ commands.',
1071
+ fieldKind: 'text',
1072
+ editable: true,
1073
+ },
1074
+ {
1075
+ type: 'field',
1076
+ key: 'require_at_in_groups',
1077
+ label: 'Require @ mention in groups',
1078
+ value: displayBoolean(selectedConnectorConfig.require_at_in_groups),
1079
+ description: 'Only process QQ group messages when the bot is explicitly mentioned.',
1080
+ fieldKind: 'boolean',
1081
+ editable: true,
1082
+ },
1083
+ {
1084
+ type: 'field',
1085
+ key: 'gateway_restart_on_config_change',
1086
+ label: 'Restart gateway on config change',
1087
+ value: displayBoolean(selectedConnectorConfig.gateway_restart_on_config_change),
1088
+ description: 'Restart the local gateway worker after QQ settings are changed.',
1089
+ fieldKind: 'boolean',
1090
+ editable: true,
1091
+ },
1092
+ {
1093
+ type: 'field',
1094
+ key: 'auto_bind_dm_to_active_quest',
1095
+ label: 'Auto-bind DM to active quest',
1096
+ value: displayBoolean(selectedConnectorConfig.auto_bind_dm_to_active_quest),
1097
+ description: 'Allow a private QQ chat to auto-bind to the current quest.',
1098
+ fieldKind: 'boolean',
1099
+ editable: true,
1100
+ },
1101
+ ...qqProfileInfoItems,
1102
+ {
1103
+ type: 'info',
1104
+ id: 'qq-detected-openid',
1105
+ label: 'Detected OpenID',
1106
+ value: displayMaybeString(selectedConnectorConfig.main_chat_id || selectedConnectorSnapshot?.main_chat_id),
1107
+ description: 'This value is auto-filled after the first private QQ message reaches DeepScientist.',
1108
+ },
1109
+ {
1110
+ type: 'info',
1111
+ id: 'qq-last-conversation',
1112
+ label: 'Last conversation',
1113
+ value: displayMaybeString(selectedConnectorSnapshot?.last_conversation_id),
1114
+ description: 'After the first QQ message arrives, the runtime also refreshes the latest conversation id here.',
1115
+ },
1116
+ {
1117
+ type: 'info',
1118
+ id: 'qq-targets',
1119
+ label: 'Discovered targets',
1120
+ value: String(selectedConnectorTargets.length || selectedConnectorSnapshot?.target_count || 0),
1121
+ description: 'Targets learned from QQ runtime activity. Bind the current quest to one of them below.',
1122
+ },
1123
+ ...bindingActionItems,
1124
+ ];
1125
+ }
1126
+ return [
1127
+ {
1128
+ type: 'action',
1129
+ id: 'open-raw-connectors',
1130
+ label: 'Open Raw connectors.yaml',
1131
+ description: 'Guided setup is not available for this connector yet. Open the raw connectors config instead.',
1132
+ },
1133
+ {
1134
+ type: 'info',
1135
+ id: 'connector-mode',
1136
+ label: 'Mode',
1137
+ value: displayMaybeString(selectedConnectorSnapshot?.mode || selectedConnectorConfig.transport),
1138
+ description: 'Current connector runtime mode.',
1139
+ },
1140
+ ...bindingActionItems,
1141
+ ];
1142
+ }, [
1143
+ baseUrl,
1144
+ qqProfileSummaries,
1145
+ qqHasMultipleProfiles,
1146
+ selectedConnectorConfig,
1147
+ selectedConnectorName,
1148
+ selectedConnectorSnapshot,
1149
+ selectedConnectorTargets,
1150
+ selectedQuestForConfig?.quest_id,
1151
+ ]);
1152
+ const connectorDirty = useMemo(() => {
1153
+ if (!selectedConnectorName || !connectorsDocument?.structured) {
1154
+ return false;
1155
+ }
1156
+ try {
1157
+ return (JSON.stringify(asRecord(connectorsDocument.structured[selectedConnectorName])) !==
1158
+ JSON.stringify(asRecord(connectorsDocument.savedStructured[selectedConnectorName])));
1159
+ }
1160
+ catch {
1161
+ return true;
1162
+ }
1163
+ }, [connectorsDocument, selectedConnectorName]);
391
1164
  const slashSuggestions = useMemo(() => {
392
1165
  const slashCommands = session?.acp_session?.slash_commands ?? [];
393
1166
  const localCommands = [
@@ -401,8 +1174,11 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
401
1174
  { name: '/stop', description: 'Stop a running quest.' },
402
1175
  { name: '/status', description: 'Show the current quest status.' },
403
1176
  { name: '/graph', description: 'Show the quest graph.' },
404
- { name: '/config', description: 'Open the local config browser.' },
405
- { name: '/config connectors', description: 'Open connectors.yaml in the local config browser.' },
1177
+ { name: '/config', description: 'Open the local config workspace.' },
1178
+ { name: '/config connectors', description: 'Open the connector list inside config.' },
1179
+ { name: '/config qq', description: 'Open the QQ connector setup.' },
1180
+ { name: '/config weixin', description: 'Open the Weixin connector setup.' },
1181
+ { name: '/config lingzhu', description: 'Open the Lingzhu connector setup.' },
406
1182
  ];
407
1183
  if (!input.startsWith('/')) {
408
1184
  return [];
@@ -418,6 +1194,111 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
418
1194
  session?.acp_session?.meta?.default_reply_interaction_id;
419
1195
  return snapshotTarget ? String(snapshotTarget) : null;
420
1196
  }, [session]);
1197
+ const configPanel = useMemo(() => {
1198
+ if (!configView) {
1199
+ return null;
1200
+ }
1201
+ if (configEditor?.kind === 'document') {
1202
+ return {
1203
+ kind: 'document-editor',
1204
+ item: configEditor.item,
1205
+ content: input,
1206
+ };
1207
+ }
1208
+ if (configEditor?.kind === 'connector-field') {
1209
+ return {
1210
+ kind: 'connector-field-editor',
1211
+ connectorName: connectorLabel(configEditor.connectorName),
1212
+ fieldLabel: configEditor.fieldLabel,
1213
+ content: input,
1214
+ description: configEditor.description,
1215
+ masked: configEditor.fieldKind === 'password',
1216
+ };
1217
+ }
1218
+ if (configView === 'root') {
1219
+ return {
1220
+ kind: 'root',
1221
+ items: CONFIG_ROOT_ENTRIES,
1222
+ selectedIndex: configIndex,
1223
+ selectedQuestId: selectedQuestForConfig?.quest_id ?? null,
1224
+ };
1225
+ }
1226
+ if (configView === 'files') {
1227
+ return {
1228
+ kind: 'files',
1229
+ title: configSectionTitle,
1230
+ description: configSectionDescription,
1231
+ items: configItems,
1232
+ selectedIndex: configIndex,
1233
+ selectedQuestId: selectedQuestForConfig?.quest_id ?? null,
1234
+ };
1235
+ }
1236
+ if (configView === 'connector-list') {
1237
+ return {
1238
+ kind: 'connector-list',
1239
+ items: connectorMenuEntries,
1240
+ selectedIndex: configIndex,
1241
+ };
1242
+ }
1243
+ if (configView === 'connector-detail' && selectedConnectorName) {
1244
+ return {
1245
+ kind: 'connector-detail',
1246
+ connectorName: selectedConnectorName,
1247
+ connectorLabel: connectorLabel(selectedConnectorName),
1248
+ selectedIndex: configIndex,
1249
+ items: connectorDetailItems,
1250
+ dirty: connectorDirty,
1251
+ snapshot: selectedConnectorSnapshot,
1252
+ contextLine: connectorContextLine,
1253
+ guideSections: connectorGuideSections,
1254
+ warning: selectedConnectorName === 'qq' && qqHasMultipleProfiles
1255
+ ? 'QQ currently has multiple profiles. TUI supports shared settings and quest binding here, but adding, deleting, or replacing profile credentials still belongs in raw connectors.yaml.'
1256
+ : null,
1257
+ };
1258
+ }
1259
+ if (configView === 'weixin-qr') {
1260
+ return {
1261
+ kind: 'weixin-qr',
1262
+ status: weixinQrState?.status || 'waiting',
1263
+ sessionKey: weixinQrState?.sessionKey || null,
1264
+ qrAscii: weixinQrState?.qrAscii || null,
1265
+ qrContent: weixinQrState?.qrContent || null,
1266
+ qrUrl: weixinQrState?.qrUrl || null,
1267
+ message: weixinQrState?.message || null,
1268
+ };
1269
+ }
1270
+ return null;
1271
+ }, [
1272
+ configEditor,
1273
+ configIndex,
1274
+ configItems,
1275
+ configSectionDescription,
1276
+ configSectionTitle,
1277
+ connectorContextLine,
1278
+ configView,
1279
+ connectorDetailItems,
1280
+ connectorDirty,
1281
+ connectorGuideSections,
1282
+ connectorMenuEntries,
1283
+ input,
1284
+ qqHasMultipleProfiles,
1285
+ selectedConnectorName,
1286
+ selectedConnectorSnapshot,
1287
+ selectedQuestForConfig?.quest_id,
1288
+ weixinQrState,
1289
+ ]);
1290
+ const configSelectionCount = useMemo(() => {
1291
+ if (!configPanel || configMode !== 'browse') {
1292
+ return 0;
1293
+ }
1294
+ if (configPanel.kind === 'root' || configPanel.kind === 'files' || configPanel.kind === 'connector-list') {
1295
+ return configPanel.items.length;
1296
+ }
1297
+ if (configPanel.kind === 'connector-detail') {
1298
+ return configPanel.items.length;
1299
+ }
1300
+ return 0;
1301
+ }, [configMode, configPanel]);
421
1302
  useEffect(() => {
422
1303
  activeQuestIdRef.current = activeQuestId;
423
1304
  }, [activeQuestId]);
@@ -437,10 +1318,15 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
437
1318
  browseQuestIdRef.current = questId;
438
1319
  setBrowseQuestId(questId);
439
1320
  }
440
- setConfigMode(null);
1321
+ setConfigView(null);
441
1322
  setConfigEditor(null);
442
1323
  setConfigItems([]);
1324
+ setConfigSectionTitle('Config');
1325
+ setConfigSectionDescription('');
443
1326
  setConfigIndex(0);
1327
+ setConnectorsDocument(null);
1328
+ setSelectedConnectorName(null);
1329
+ setWeixinQrState(null);
444
1330
  setQuestPanelMode(null);
445
1331
  historyRef.current = [];
446
1332
  pendingHistoryItemsRef.current = [];
@@ -454,10 +1340,15 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
454
1340
  activeQuestIdRef.current = null;
455
1341
  browseQuestIdRef.current = null;
456
1342
  setActiveQuestId(null);
457
- setConfigMode(null);
1343
+ setConfigView(null);
458
1344
  setConfigEditor(null);
459
1345
  setConfigItems([]);
1346
+ setConfigSectionTitle('Config');
1347
+ setConfigSectionDescription('');
460
1348
  setConfigIndex(0);
1349
+ setConnectorsDocument(null);
1350
+ setSelectedConnectorName(null);
1351
+ setWeixinQrState(null);
461
1352
  setQuestPanelMode(null);
462
1353
  setSession(null);
463
1354
  historyRef.current = [];
@@ -553,8 +1444,11 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
553
1444
  }
554
1445
  }, [baseUrl]);
555
1446
  const openQuestPanel = useCallback((mode) => {
556
- setConfigMode(null);
1447
+ setConfigView(null);
557
1448
  setConfigEditor(null);
1449
+ setConnectorsDocument(null);
1450
+ setSelectedConnectorName(null);
1451
+ setWeixinQrState(null);
558
1452
  const candidates = getPanelQuests(mode, quests);
559
1453
  setQuestPanelMode(mode);
560
1454
  if (candidates.length === 0) {
@@ -581,15 +1475,60 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
581
1475
  : 'Resume quest · use arrows and Enter to resume.');
582
1476
  }, [activeQuestId, browseQuestId, quests]);
583
1477
  const closeConfigScreen = useCallback((nextStatus) => {
584
- setConfigMode(null);
1478
+ setConfigView(null);
585
1479
  setConfigEditor(null);
586
1480
  setConfigItems([]);
1481
+ setConfigSectionTitle('Config');
1482
+ setConfigSectionDescription('');
587
1483
  setConfigIndex(0);
1484
+ setConnectorsDocument(null);
1485
+ setSelectedConnectorName(null);
1486
+ setWeixinQrState(null);
588
1487
  setInput('');
589
1488
  if (nextStatus) {
590
1489
  setStatusLine(nextStatus);
591
1490
  }
592
1491
  }, []);
1492
+ const loadConnectorsDocument = useCallback(async () => {
1493
+ const payload = await client.configDocument(baseUrl, 'connectors');
1494
+ const structured = asRecord(payload.meta?.structured_config);
1495
+ return {
1496
+ item: {
1497
+ id: 'global:connectors',
1498
+ scope: 'global',
1499
+ name: 'connectors',
1500
+ title: payload.title || 'connectors.yaml',
1501
+ path: payload.path,
1502
+ writable: true,
1503
+ configName: 'connectors',
1504
+ },
1505
+ revision: payload.revision,
1506
+ savedStructured: cloneStructured(structured),
1507
+ structured: cloneStructured(structured),
1508
+ };
1509
+ }, [baseUrl]);
1510
+ const setWeixinQrPayload = useCallback(async (payload) => {
1511
+ const qrContent = String(payload.qrcode_content || '').trim();
1512
+ const qrUrl = String(payload.qrcode_url || '').trim();
1513
+ let qrAscii = '';
1514
+ const renderable = qrContent || qrUrl;
1515
+ if (renderable && !looksLikeWeixinQrImageUrl(qrUrl || qrContent)) {
1516
+ try {
1517
+ qrAscii = await renderQrAscii(qrContent || qrUrl);
1518
+ }
1519
+ catch {
1520
+ qrAscii = '';
1521
+ }
1522
+ }
1523
+ setWeixinQrState({
1524
+ sessionKey: String(payload.session_key || '').trim(),
1525
+ status: String(payload.status || 'wait').trim() || 'wait',
1526
+ qrContent: qrContent || undefined,
1527
+ qrUrl: qrUrl || undefined,
1528
+ qrAscii: qrAscii || undefined,
1529
+ message: String(payload.message || '').trim() || undefined,
1530
+ });
1531
+ }, []);
593
1532
  const openConfigEditor = useCallback(async (item) => {
594
1533
  try {
595
1534
  let payload;
@@ -605,8 +1544,9 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
605
1544
  payload = await client.openDocument(baseUrl, questId, item.documentId);
606
1545
  }
607
1546
  setQuestPanelMode(null);
608
- setConfigMode('edit');
1547
+ setConfigView('files');
609
1548
  setConfigEditor({
1549
+ kind: 'document',
610
1550
  item,
611
1551
  revision: payload.revision,
612
1552
  content: payload.content,
@@ -618,19 +1558,22 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
618
1558
  setStatusLine(error instanceof Error ? error.message : String(error));
619
1559
  }
620
1560
  }, [baseUrl, selectedQuestForConfig]);
621
- const openConfigBrowser = useCallback(async (target) => {
1561
+ const openConfigFiles = useCallback(async (scope, target) => {
622
1562
  try {
623
- const globalEntries = await client.configFiles(baseUrl);
624
- const globalItems = buildGlobalConfigItems(globalEntries);
625
- const questItems = buildQuestConfigItems(selectedQuestForConfig?.quest_id ?? null, selectedQuestForConfig?.quest_root);
626
- const nextItems = [...globalItems, ...questItems];
1563
+ const nextItems = scope === 'global'
1564
+ ? buildGlobalConfigItems(await client.configFiles(baseUrl))
1565
+ : buildQuestConfigItems(selectedQuestForConfig?.quest_id ?? null, selectedQuestForConfig?.quest_root);
627
1566
  setQuestPanelMode(null);
1567
+ setConfigView('files');
628
1568
  setConfigItems(nextItems);
1569
+ setConfigSectionTitle(scope === 'global' ? 'Global Config Files' : 'Current Quest Files');
1570
+ setConfigSectionDescription(scope === 'global'
1571
+ ? 'Choose a global config file and press Enter to edit it.'
1572
+ : 'Choose a quest-local config file and press Enter to edit it.');
629
1573
  setConfigEditor(null);
630
- setConfigMode('browse');
631
1574
  if (nextItems.length === 0) {
632
1575
  setConfigIndex(0);
633
- setStatusLine('No config files available.');
1576
+ setStatusLine(scope === 'global' ? 'No global config files available.' : 'No current quest config files available.');
634
1577
  return;
635
1578
  }
636
1579
  if (target) {
@@ -642,17 +1585,177 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
642
1585
  }
643
1586
  }
644
1587
  setConfigIndex(0);
645
- setStatusLine('Config browser · global and current quest config files.');
1588
+ setStatusLine(scope === 'global' ? 'Global config files.' : 'Current quest config files.');
646
1589
  }
647
1590
  catch (error) {
648
1591
  setStatusLine(error instanceof Error ? error.message : String(error));
649
1592
  }
650
1593
  }, [baseUrl, openConfigEditor, selectedQuestForConfig]);
1594
+ const openConfigRoot = useCallback((nextStatus = 'Config · choose a section with arrows and Enter.') => {
1595
+ setQuestPanelMode(null);
1596
+ setConfigView('root');
1597
+ setConfigEditor(null);
1598
+ setConfigItems([]);
1599
+ setConfigSectionTitle('Config');
1600
+ setConfigSectionDescription('');
1601
+ setConfigIndex(0);
1602
+ setSelectedConnectorName(null);
1603
+ setWeixinQrState(null);
1604
+ setInput('');
1605
+ setStatusLine(nextStatus);
1606
+ }, []);
1607
+ const openConfigBrowser = useCallback((nextStatus) => {
1608
+ openConfigRoot(nextStatus);
1609
+ }, [openConfigRoot]);
1610
+ const openConnectorBrowser = useCallback(async (targetConnector) => {
1611
+ try {
1612
+ const document = await loadConnectorsDocument();
1613
+ const names = connectorNamesFromStructuredConfig(document.structured);
1614
+ const browseNames = names.length > 0 ? names : CONNECTOR_ORDER;
1615
+ setQuestPanelMode(null);
1616
+ setConfigView('connector-list');
1617
+ setConfigEditor(null);
1618
+ setConnectorsDocument(document);
1619
+ setSelectedConnectorName(null);
1620
+ setWeixinQrState(null);
1621
+ setInput('');
1622
+ if (targetConnector) {
1623
+ const directIndex = browseNames.findIndex((item) => item === targetConnector);
1624
+ setConfigIndex(directIndex >= 0 ? directIndex : 0);
1625
+ return;
1626
+ }
1627
+ setConfigIndex(0);
1628
+ setStatusLine('Connectors · choose a connector with arrows and Enter.');
1629
+ }
1630
+ catch (error) {
1631
+ setStatusLine(error instanceof Error ? error.message : String(error));
1632
+ }
1633
+ }, [loadConnectorsDocument]);
1634
+ const updateConnectorDraft = useCallback((connectorName, patch) => {
1635
+ setConnectorsDocument((current) => {
1636
+ if (!current) {
1637
+ return current;
1638
+ }
1639
+ const nextStructured = cloneStructured(current.structured);
1640
+ const currentConnector = asRecord(nextStructured[connectorName]);
1641
+ nextStructured[connectorName] = {
1642
+ ...currentConnector,
1643
+ ...patch,
1644
+ };
1645
+ return {
1646
+ ...current,
1647
+ structured: nextStructured,
1648
+ };
1649
+ });
1650
+ }, []);
1651
+ const openConnectorDetail = useCallback(async (connectorName) => {
1652
+ try {
1653
+ let currentDocument = connectorsDocument ?? (await loadConnectorsDocument());
1654
+ if (connectorName === 'lingzhu') {
1655
+ const currentLingzhu = asRecord(currentDocument.structured.lingzhu);
1656
+ const patch = {};
1657
+ if (!resolveLingzhuAuthAk(currentLingzhu.auth_ak)) {
1658
+ patch.auth_ak = createLingzhuAk();
1659
+ }
1660
+ if (!String(currentLingzhu.agent_id || '').trim()) {
1661
+ patch.agent_id = LINGZHU_PUBLIC_AGENT_ID;
1662
+ }
1663
+ if (!String(currentLingzhu.local_host || '').trim()) {
1664
+ patch.local_host = '127.0.0.1';
1665
+ }
1666
+ if (!String(currentLingzhu.gateway_port || '').trim()) {
1667
+ try {
1668
+ patch.gateway_port = String(new URL(baseUrl).port || '20999');
1669
+ }
1670
+ catch {
1671
+ patch.gateway_port = '20999';
1672
+ }
1673
+ }
1674
+ if (!String(currentLingzhu.public_base_url || '').trim()) {
1675
+ try {
1676
+ const currentBaseUrl = new URL(baseUrl);
1677
+ if (currentBaseUrl.hostname === '0.0.0.0') {
1678
+ currentBaseUrl.hostname = '127.0.0.1';
1679
+ }
1680
+ const resolvedBaseUrl = currentBaseUrl.toString().replace(/\/$/, '');
1681
+ if (looksLikePublicBaseUrl(resolvedBaseUrl)) {
1682
+ patch.public_base_url = resolvedBaseUrl;
1683
+ }
1684
+ }
1685
+ catch {
1686
+ // ignore parse failures
1687
+ }
1688
+ }
1689
+ if (Object.keys(patch).length > 0) {
1690
+ currentDocument = {
1691
+ ...currentDocument,
1692
+ structured: {
1693
+ ...cloneStructured(currentDocument.structured),
1694
+ lingzhu: {
1695
+ ...currentLingzhu,
1696
+ ...patch,
1697
+ },
1698
+ },
1699
+ };
1700
+ }
1701
+ }
1702
+ setConnectorsDocument(currentDocument);
1703
+ setSelectedConnectorName(connectorName);
1704
+ setConfigView('connector-detail');
1705
+ setConfigEditor(null);
1706
+ setWeixinQrState(null);
1707
+ setConfigIndex(0);
1708
+ setInput('');
1709
+ setStatusLine(`${connectorLabel(connectorName)} connector · arrows to navigate, Enter to edit or run an action.`);
1710
+ }
1711
+ catch (error) {
1712
+ setStatusLine(error instanceof Error ? error.message : String(error));
1713
+ }
1714
+ }, [baseUrl, connectorsDocument, loadConnectorsDocument]);
1715
+ const openRawConnectorsEditor = useCallback(async () => {
1716
+ const document = connectorsDocument ?? (await loadConnectorsDocument());
1717
+ setConnectorsDocument(document);
1718
+ await openConfigEditor(document.item);
1719
+ }, [connectorsDocument, loadConnectorsDocument, openConfigEditor]);
1720
+ const saveConnectorDraft = useCallback(async () => {
1721
+ if (!connectorsDocument) {
1722
+ return;
1723
+ }
1724
+ try {
1725
+ const structuredToSave = (() => {
1726
+ if (!selectedConnectorName) {
1727
+ return connectorsDocument.structured;
1728
+ }
1729
+ const merged = cloneStructured(connectorsDocument.savedStructured);
1730
+ merged[selectedConnectorName] = cloneStructured(asRecord(connectorsDocument.structured[selectedConnectorName]));
1731
+ return merged;
1732
+ })();
1733
+ const payload = await client.saveStructuredConfig(baseUrl, 'connectors', structuredToSave, connectorsDocument.revision);
1734
+ if (!payload.ok) {
1735
+ setStatusLine(payload.message || payload.errors?.[0] || 'Connector save failed.');
1736
+ return;
1737
+ }
1738
+ const refreshedDocument = await loadConnectorsDocument();
1739
+ setConnectorsDocument(refreshedDocument);
1740
+ await refresh(true, activeQuestIdRef.current);
1741
+ setStatusLine(`Saved ${selectedConnectorName ? connectorLabel(selectedConnectorName) : 'connectors'} settings.`);
1742
+ }
1743
+ catch (error) {
1744
+ setStatusLine(error instanceof Error ? error.message : String(error));
1745
+ }
1746
+ }, [baseUrl, connectorsDocument, loadConnectorsDocument, refresh, selectedConnectorName]);
651
1747
  const saveConfigEditor = useCallback(async (content) => {
652
1748
  if (!configEditor) {
653
1749
  return;
654
1750
  }
655
1751
  try {
1752
+ if (configEditor.kind === 'connector-field') {
1753
+ updateConnectorDraft(configEditor.connectorName, { [configEditor.fieldKey]: content });
1754
+ setInput('');
1755
+ setConfigEditor(null);
1756
+ setStatusLine(`Updated ${configEditor.fieldLabel} in draft. Save the connector to persist it.`);
1757
+ return;
1758
+ }
656
1759
  if (configEditor.item.scope === 'global' && configEditor.item.configName) {
657
1760
  const payload = await client.saveConfig(baseUrl, configEditor.item.configName, content, configEditor.revision);
658
1761
  if (payload.ok === false) {
@@ -674,14 +1777,199 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
674
1777
  }
675
1778
  setInput('');
676
1779
  setConfigEditor(null);
677
- setConfigMode('browse');
1780
+ setConfigView('files');
678
1781
  setStatusLine(`Saved ${configEditor.item.title}`);
679
1782
  await refresh(true, activeQuestId);
680
1783
  }
681
1784
  catch (error) {
682
1785
  setStatusLine(error instanceof Error ? error.message : String(error));
683
1786
  }
684
- }, [activeQuestId, baseUrl, configEditor, refresh, selectedQuestForConfig]);
1787
+ }, [activeQuestId, baseUrl, configEditor, refresh, selectedQuestForConfig, updateConnectorDraft]);
1788
+ const openConnectorFieldEditor = useCallback((fieldKey, fieldLabel, description, fieldKind) => {
1789
+ if (!selectedConnectorName) {
1790
+ return;
1791
+ }
1792
+ const currentValue = String(selectedConnectorConfig[fieldKey] ?? '').trim();
1793
+ setConfigEditor({
1794
+ kind: 'connector-field',
1795
+ connectorName: selectedConnectorName,
1796
+ fieldKey,
1797
+ fieldLabel,
1798
+ description,
1799
+ fieldKind,
1800
+ content: currentValue,
1801
+ });
1802
+ setInput(currentValue);
1803
+ setStatusLine(`Editing ${fieldLabel} · Enter apply · Esc cancel`);
1804
+ }, [selectedConnectorConfig, selectedConnectorName]);
1805
+ const toggleConnectorBooleanField = useCallback((fieldKey, fieldLabel) => {
1806
+ if (!selectedConnectorName) {
1807
+ return;
1808
+ }
1809
+ const currentValue = Boolean(selectedConnectorConfig[fieldKey]);
1810
+ updateConnectorDraft(selectedConnectorName, { [fieldKey]: !currentValue });
1811
+ setStatusLine(`Updated ${fieldLabel} in draft. Save the connector to persist it.`);
1812
+ }, [selectedConnectorConfig, selectedConnectorName, updateConnectorDraft]);
1813
+ const handleConnectorAction = useCallback(async (actionId) => {
1814
+ if (!selectedConnectorName) {
1815
+ return;
1816
+ }
1817
+ const bindTarget = parseConnectorBindActionId(actionId);
1818
+ if (bindTarget) {
1819
+ const questId = selectedQuestForConfig?.quest_id;
1820
+ if (!questId) {
1821
+ setStatusLine('Open a quest first, then bind the connector target to that quest.');
1822
+ return;
1823
+ }
1824
+ await client.updateQuestBindings(baseUrl, questId, {
1825
+ connector: bindTarget.connectorName,
1826
+ conversation_id: bindTarget.conversationId,
1827
+ force: true,
1828
+ });
1829
+ await refresh(true, questId);
1830
+ setStatusLine(`Bound ${connectorLabel(bindTarget.connectorName)} target to ${questId}.`);
1831
+ return;
1832
+ }
1833
+ const unbindConnectorName = parseConnectorUnbindActionId(actionId);
1834
+ if (unbindConnectorName) {
1835
+ const questId = selectedQuestForConfig?.quest_id;
1836
+ if (!questId) {
1837
+ setStatusLine('Open a quest first, then remove the connector binding from that quest.');
1838
+ return;
1839
+ }
1840
+ await client.updateQuestBindings(baseUrl, questId, {
1841
+ connector: unbindConnectorName,
1842
+ conversation_id: null,
1843
+ force: true,
1844
+ });
1845
+ await refresh(true, questId);
1846
+ setStatusLine(`Unbound ${connectorLabel(unbindConnectorName)} from ${questId}.`);
1847
+ return;
1848
+ }
1849
+ if (actionId === 'save-connector') {
1850
+ await saveConnectorDraft();
1851
+ return;
1852
+ }
1853
+ if (actionId === 'refresh-connector') {
1854
+ if (selectedConnectorName === 'weixin' || selectedConnectorName === 'qq' || selectedConnectorName === 'lingzhu') {
1855
+ const document = await loadConnectorsDocument();
1856
+ setConnectorsDocument(document);
1857
+ }
1858
+ await refresh(true, activeQuestIdRef.current);
1859
+ setStatusLine(`${connectorLabel(selectedConnectorName)} connector refreshed.`);
1860
+ return;
1861
+ }
1862
+ if (actionId === 'generate-lingzhu-ak') {
1863
+ updateConnectorDraft('lingzhu', { auth_ak: createLingzhuAk() });
1864
+ setStatusLine('Generated a new Lingzhu Custom agent AK in draft.');
1865
+ return;
1866
+ }
1867
+ if (actionId === 'weixin-start-login') {
1868
+ const payload = await client.startWeixinQrLogin(baseUrl, Boolean(selectedConnectorSnapshot?.enabled));
1869
+ if (!payload.ok || !payload.session_key) {
1870
+ setStatusLine(payload.message || 'Failed to start Weixin QR login.');
1871
+ return;
1872
+ }
1873
+ setConfigView('weixin-qr');
1874
+ setConfigIndex(0);
1875
+ await setWeixinQrPayload({
1876
+ session_key: payload.session_key,
1877
+ status: 'wait',
1878
+ qrcode_content: payload.qrcode_content,
1879
+ qrcode_url: payload.qrcode_url,
1880
+ message: payload.message,
1881
+ });
1882
+ setStatusLine('Weixin QR login started. Scan the QR code with WeChat.');
1883
+ return;
1884
+ }
1885
+ if (actionId === 'open-raw-connectors') {
1886
+ await openRawConnectorsEditor();
1887
+ }
1888
+ }, [
1889
+ baseUrl,
1890
+ loadConnectorsDocument,
1891
+ openRawConnectorsEditor,
1892
+ refresh,
1893
+ saveConnectorDraft,
1894
+ selectedConnectorName,
1895
+ selectedConnectorSnapshot?.enabled,
1896
+ selectedQuestForConfig?.quest_id,
1897
+ setWeixinQrPayload,
1898
+ updateConnectorDraft,
1899
+ ]);
1900
+ const handleConfigBrowseSelection = useCallback(async () => {
1901
+ if (!configPanel) {
1902
+ return;
1903
+ }
1904
+ if (configPanel.kind === 'root') {
1905
+ const selected = configPanel.items[configIndex];
1906
+ if (!selected) {
1907
+ return;
1908
+ }
1909
+ if (selected.id === 'connectors') {
1910
+ await openConnectorBrowser();
1911
+ return;
1912
+ }
1913
+ if (selected.id === 'global-files') {
1914
+ await openConfigFiles('global');
1915
+ return;
1916
+ }
1917
+ if (selected.id === 'quest-files') {
1918
+ await openConfigFiles('quest');
1919
+ }
1920
+ return;
1921
+ }
1922
+ if (configPanel.kind === 'files') {
1923
+ const selected = configPanel.items[configIndex] ?? null;
1924
+ if (!selected) {
1925
+ setStatusLine('No config file selected.');
1926
+ return;
1927
+ }
1928
+ await openConfigEditor(selected);
1929
+ return;
1930
+ }
1931
+ if (configPanel.kind === 'connector-list') {
1932
+ const selected = configPanel.items[configIndex] ?? null;
1933
+ if (!selected) {
1934
+ setStatusLine('No connector selected.');
1935
+ return;
1936
+ }
1937
+ await openConnectorDetail(selected.name);
1938
+ return;
1939
+ }
1940
+ if (configPanel.kind === 'connector-detail') {
1941
+ const selected = configPanel.items[configIndex] ?? null;
1942
+ if (!selected) {
1943
+ return;
1944
+ }
1945
+ if (selected.type === 'action') {
1946
+ if (!selected.disabled) {
1947
+ await handleConnectorAction(selected.id);
1948
+ }
1949
+ return;
1950
+ }
1951
+ if (selected.type === 'field') {
1952
+ if (!selected.editable) {
1953
+ return;
1954
+ }
1955
+ if (selected.fieldKind === 'boolean') {
1956
+ toggleConnectorBooleanField(selected.key, selected.label);
1957
+ return;
1958
+ }
1959
+ openConnectorFieldEditor(selected.key, selected.label, selected.description, selected.fieldKind);
1960
+ }
1961
+ }
1962
+ }, [
1963
+ configIndex,
1964
+ configPanel,
1965
+ handleConnectorAction,
1966
+ openConfigEditor,
1967
+ openConfigFiles,
1968
+ openConnectorBrowser,
1969
+ openConnectorDetail,
1970
+ openConnectorFieldEditor,
1971
+ toggleConnectorBooleanField,
1972
+ ]);
685
1973
  const closeQuestPanel = useCallback((nextStatus) => {
686
1974
  setQuestPanelMode(null);
687
1975
  if (nextStatus) {
@@ -713,10 +2001,10 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
713
2001
  }, [baseUrl, closeQuestPanel, focusQuest, panelQuests, questPanelIndex, questPanelMode]);
714
2002
  const cycleQuest = useCallback((direction) => {
715
2003
  if (configMode === 'browse') {
716
- if (configItems.length === 0) {
2004
+ if (configSelectionCount === 0) {
717
2005
  return;
718
2006
  }
719
- setConfigIndex((previous) => (previous + direction + configItems.length) % configItems.length);
2007
+ setConfigIndex((previous) => (previous + direction + configSelectionCount) % configSelectionCount);
720
2008
  return;
721
2009
  }
722
2010
  if (questPanelMode) {
@@ -744,7 +2032,7 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
744
2032
  const nextIndex = index < 0 ? 0 : (index + direction + quests.length) % quests.length;
745
2033
  const nextQuestId = quests[nextIndex]?.quest_id ?? null;
746
2034
  setBrowseQuestId(nextQuestId);
747
- }, [activeQuestId, browseQuestId, configItems.length, configMode, panelQuests, questPanelMode, quests]);
2035
+ }, [activeQuestId, browseQuestId, configMode, configSelectionCount, panelQuests, questPanelMode, quests]);
748
2036
  useEffect(() => {
749
2037
  void refresh(true, initialQuestId);
750
2038
  }, [initialQuestId, refresh]);
@@ -852,6 +2140,63 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
852
2140
  }
853
2141
  };
854
2142
  }, [activeQuestId, baseUrl, refresh]);
2143
+ useEffect(() => {
2144
+ if (configView !== 'weixin-qr' || !weixinQrState?.sessionKey) {
2145
+ return;
2146
+ }
2147
+ let cancelled = false;
2148
+ let timer = null;
2149
+ const poll = async () => {
2150
+ try {
2151
+ const payload = await client.waitWeixinQrLogin(baseUrl, weixinQrState.sessionKey, 1500);
2152
+ if (cancelled) {
2153
+ return;
2154
+ }
2155
+ await setWeixinQrPayload({
2156
+ session_key: payload.session_key || weixinQrState.sessionKey,
2157
+ status: payload.status,
2158
+ qrcode_content: payload.qrcode_content,
2159
+ qrcode_url: payload.qrcode_url,
2160
+ message: payload.message,
2161
+ });
2162
+ if (payload.connected) {
2163
+ const document = await loadConnectorsDocument();
2164
+ if (cancelled) {
2165
+ return;
2166
+ }
2167
+ setConnectorsDocument(document);
2168
+ await refresh(true, activeQuestIdRef.current);
2169
+ if (cancelled) {
2170
+ return;
2171
+ }
2172
+ setSelectedConnectorName('weixin');
2173
+ setConfigView('connector-detail');
2174
+ setConfigIndex(0);
2175
+ setStatusLine(payload.message || 'Weixin login succeeded and the connector config was saved.');
2176
+ return;
2177
+ }
2178
+ }
2179
+ catch (error) {
2180
+ if (!cancelled) {
2181
+ setStatusLine(error instanceof Error ? error.message : String(error));
2182
+ }
2183
+ }
2184
+ if (!cancelled) {
2185
+ timer = setTimeout(() => {
2186
+ void poll();
2187
+ }, 1200);
2188
+ }
2189
+ };
2190
+ timer = setTimeout(() => {
2191
+ void poll();
2192
+ }, 400);
2193
+ return () => {
2194
+ cancelled = true;
2195
+ if (timer) {
2196
+ clearTimeout(timer);
2197
+ }
2198
+ };
2199
+ }, [baseUrl, configView, loadConnectorsDocument, refresh, setWeixinQrPayload, weixinQrState?.sessionKey]);
855
2200
  const submit = useCallback(async (override) => {
856
2201
  const rawText = override ?? input;
857
2202
  if (configMode === 'edit' && configEditor) {
@@ -860,12 +2205,7 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
860
2205
  }
861
2206
  const text = rawText.trim();
862
2207
  if (configMode === 'browse' && !text) {
863
- const selected = configItems[configIndex] ?? null;
864
- if (!selected) {
865
- setStatusLine('No config file selected.');
866
- return;
867
- }
868
- await openConfigEditor(selected);
2208
+ await handleConfigBrowseSelection();
869
2209
  return;
870
2210
  }
871
2211
  if (configMode === 'browse' && !text.startsWith('/')) {
@@ -941,11 +2281,13 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
941
2281
  return;
942
2282
  }
943
2283
  if (slash?.name === '/resume') {
944
- if (!slash.arg) {
2284
+ const target = slash.arg
2285
+ ? resolveQuestToken(slash.arg, quests)
2286
+ : quests.find((quest) => quest.quest_id === (activeQuestId || browseQuestId)) ?? null;
2287
+ if (!slash.arg && !target) {
945
2288
  openQuestPanel('resume');
946
2289
  return;
947
2290
  }
948
- const target = resolveQuestToken(slash.arg, quests);
949
2291
  if (!target) {
950
2292
  setStatusLine(`Unknown quest · ${slash.arg}`);
951
2293
  return;
@@ -969,7 +2311,43 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
969
2311
  return;
970
2312
  }
971
2313
  if (slash?.name === '/config') {
972
- await openConfigBrowser(slash.arg || undefined);
2314
+ const arg = String(slash.arg || '').trim();
2315
+ if (!arg) {
2316
+ openConfigRoot();
2317
+ return;
2318
+ }
2319
+ const tokens = arg.split(/\s+/).filter(Boolean);
2320
+ const first = tokens[0]?.toLowerCase() || '';
2321
+ const second = tokens[1]?.toLowerCase() || '';
2322
+ const directConnector = resolveManagedConnectorName(first);
2323
+ const nestedConnector = first === 'connectors' ? resolveManagedConnectorName(second) : null;
2324
+ if (first === 'connectors' && nestedConnector) {
2325
+ await openConnectorBrowser(nestedConnector);
2326
+ await openConnectorDetail(nestedConnector);
2327
+ return;
2328
+ }
2329
+ if (directConnector) {
2330
+ await openConnectorBrowser(directConnector);
2331
+ await openConnectorDetail(directConnector);
2332
+ return;
2333
+ }
2334
+ if (first === 'connectors') {
2335
+ await openConnectorBrowser();
2336
+ return;
2337
+ }
2338
+ if (first === 'global') {
2339
+ await openConfigFiles('global');
2340
+ return;
2341
+ }
2342
+ if (first === 'quest') {
2343
+ await openConfigFiles('quest');
2344
+ return;
2345
+ }
2346
+ if (first === 'connectors.yaml') {
2347
+ await openConfigFiles('global', 'connectors');
2348
+ return;
2349
+ }
2350
+ await openConfigFiles('global', arg);
973
2351
  return;
974
2352
  }
975
2353
  if (slash?.name === '/new') {
@@ -1105,15 +2483,17 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
1105
2483
  browseQuestId,
1106
2484
  configEditor,
1107
2485
  configIndex,
1108
- configItems,
1109
2486
  configMode,
1110
2487
  closeQuestPanel,
1111
2488
  focusQuest,
2489
+ handleConfigBrowseSelection,
1112
2490
  handleQuestPanelSelection,
1113
2491
  input,
1114
2492
  leaveQuest,
1115
- openConfigBrowser,
1116
- openConfigEditor,
2493
+ openConfigFiles,
2494
+ openConfigRoot,
2495
+ openConnectorBrowser,
2496
+ openConnectorDetail,
1117
2497
  openQuestPanel,
1118
2498
  questPanelMode,
1119
2499
  quests,
@@ -1121,6 +2501,32 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
1121
2501
  replyTargetId,
1122
2502
  saveConfigEditor,
1123
2503
  ]);
2504
+ const backFromConfigBrowse = useCallback(() => {
2505
+ if (!configView) {
2506
+ return;
2507
+ }
2508
+ if (configView === 'root') {
2509
+ closeConfigScreen('Config browser closed.');
2510
+ return;
2511
+ }
2512
+ if (configView === 'files' || configView === 'connector-list') {
2513
+ openConfigRoot();
2514
+ return;
2515
+ }
2516
+ if (configView === 'connector-detail') {
2517
+ setConfigView('connector-list');
2518
+ setConfigIndex(0);
2519
+ setSelectedConnectorName(null);
2520
+ setWeixinQrState(null);
2521
+ setStatusLine('Connectors · choose a connector with arrows and Enter.');
2522
+ return;
2523
+ }
2524
+ if (configView === 'weixin-qr') {
2525
+ setConfigView('connector-detail');
2526
+ setConfigIndex(0);
2527
+ setStatusLine('Back to Weixin connector details.');
2528
+ }
2529
+ }, [closeConfigScreen, configView, openConfigRoot]);
1124
2530
  useInput((value, key) => {
1125
2531
  const canBrowseSelection = configMode === 'browse' || Boolean(questPanelMode);
1126
2532
  const canBrowseHomeQuests = !activeQuestId && input.length === 0;
@@ -1138,7 +2544,7 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
1138
2544
  return;
1139
2545
  }
1140
2546
  if (key.ctrl && value.toLowerCase() === 'g') {
1141
- void openConfigBrowser();
2547
+ openConfigRoot();
1142
2548
  return;
1143
2549
  }
1144
2550
  if (key.ctrl && value.toLowerCase() === 'b') {
@@ -1152,14 +2558,13 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
1152
2558
  }
1153
2559
  if (key.escape) {
1154
2560
  if (configMode === 'edit') {
1155
- setConfigMode('browse');
1156
2561
  setConfigEditor(null);
1157
2562
  setInput('');
1158
2563
  setStatusLine('Config edit cancelled.');
1159
2564
  return;
1160
2565
  }
1161
2566
  if (configMode === 'browse') {
1162
- closeConfigScreen('Config browser closed.');
2567
+ backFromConfigBrowse();
1163
2568
  return;
1164
2569
  }
1165
2570
  if (questPanelMode) {
@@ -1171,10 +2576,7 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
1171
2576
  }
1172
2577
  if (submitRequested) {
1173
2578
  if (configMode === 'browse' && input.trim().length === 0) {
1174
- const selected = configItems[configIndex] ?? null;
1175
- if (selected) {
1176
- void openConfigEditor(selected);
1177
- }
2579
+ void handleConfigBrowseSelection();
1178
2580
  return;
1179
2581
  }
1180
2582
  }
@@ -1187,12 +2589,7 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
1187
2589
  return;
1188
2590
  }
1189
2591
  });
1190
- return (React.createElement(DefaultAppLayout, { baseUrl: baseUrl, quests: quests, activeQuestId: activeQuestId, browseQuestId: browseQuestId, configMode: configMode, configItems: configItems, configIndex: configIndex, configEditor: configEditor
1191
- ? {
1192
- item: configEditor.item,
1193
- content: input,
1194
- }
1195
- : null, snapshot: activeQuest, session: session, connectors: connectors, history: history, pendingHistoryItems: pendingHistoryItems, input: input, connectionState: connectionState, statusLine: statusLine, suggestions: slashSuggestions, questPanelMode: questPanelMode, questPanelQuests: panelQuests, questPanelIndex: questPanelIndex, onQuestPanelMove: (direction) => {
2592
+ return (React.createElement(DefaultAppLayout, { baseUrl: baseUrl, quests: quests, activeQuestId: activeQuestId, browseQuestId: browseQuestId, configMode: configMode, configPanel: configPanel, snapshot: activeQuest, session: session, connectors: connectors, history: history, pendingHistoryItems: pendingHistoryItems, input: input, connectionState: connectionState, statusLine: statusLine, suggestions: slashSuggestions, questPanelMode: questPanelMode, questPanelQuests: panelQuests, questPanelIndex: questPanelIndex, onQuestPanelMove: (direction) => {
1196
2593
  cycleQuest(direction);
1197
2594
  }, onQuestPanelConfirm: () => {
1198
2595
  void handleQuestPanelSelection();
@@ -1201,10 +2598,7 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
1201
2598
  }, onChange: setInput, onSubmit: (override) => {
1202
2599
  const submitted = override ?? input;
1203
2600
  if (configMode === 'browse' && !String(submitted).trim()) {
1204
- const selected = configItems[configIndex] ?? null;
1205
- if (selected) {
1206
- void openConfigEditor(selected);
1207
- }
2601
+ void handleConfigBrowseSelection();
1208
2602
  return;
1209
2603
  }
1210
2604
  if (questPanelMode && !String(submitted).trim()) {
@@ -1218,14 +2612,13 @@ export const AppContainer = ({ baseUrl, initialQuestId = null, }) => {
1218
2612
  void submit(override);
1219
2613
  }, onCancel: () => {
1220
2614
  if (configMode === 'edit') {
1221
- setConfigMode('browse');
1222
2615
  setConfigEditor(null);
1223
2616
  setInput('');
1224
2617
  setStatusLine('Config edit cancelled.');
1225
2618
  return;
1226
2619
  }
1227
2620
  if (configMode === 'browse') {
1228
- closeConfigScreen('Config browser closed.');
2621
+ backFromConfigBrowse();
1229
2622
  return;
1230
2623
  }
1231
2624
  if (questPanelMode) {