@modesto_ma/dsh-channels 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -6460,13 +6460,21 @@ var FeishuService = class {
6460
6460
  started = false;
6461
6461
  /** Epoch ms when `start()` completed (uptime for `/status`). */
6462
6462
  startedAt;
6463
- /** Last failed `boot()` message, surfaced in the Web settings panel. */
6463
+ /** Last failed connection attempt, surfaced in the Web settings panel. */
6464
6464
  lastStartError;
6465
+ /** Whether both Feishu application credentials currently resolve. */
6466
+ configured = false;
6467
+ /** Names of the missing credential references (never their values). */
6468
+ missingConfigItems = [];
6469
+ /** Guards the panel's explicit connect action (distinct from a restart). */
6470
+ connecting = false;
6465
6471
  /** Effective config captured at the last successful connect (restart-pending diff). */
6466
6472
  runningConfig;
6467
6473
  /** Guards overlapping panel-triggered reconnects. */
6468
6474
  restarting = false;
6469
6475
  unlisten;
6476
+ /** Disposers for host-scoped listeners registered by `start()`. */
6477
+ unlistenHost = [];
6470
6478
  /** Periodic route-GC timer (unref'd; only armed when `routeTtlMs > 0`). */
6471
6479
  gcTimer;
6472
6480
  /** Periodic workspace-retention timer (only armed when auto cleanup is on). */
@@ -6565,24 +6573,82 @@ var FeishuService = class {
6565
6573
  });
6566
6574
  }
6567
6575
  /**
6568
- * Load the Web overlay, then open the channel. Startup errors are recorded
6569
- * for the settings panel and the partial service is torn down before the
6570
- * error is rethrown (the plugin fiber still fails the same way).
6576
+ * Load the Web overlay and open the long connection when — and only when —
6577
+ * the Feishu application credentials are complete.
6578
+ *
6579
+ * An incomplete configuration is a normal state, not an error: the plugin
6580
+ * stays loaded so the Web settings panel can guide the user through the
6581
+ * credentials form, and no connection is attempted. A failed connection
6582
+ * attempt is recorded for the panel instead of failing the plugin fiber.
6571
6583
  */
6572
6584
  async boot() {
6585
+ if (this.connecting || this.restarting) return;
6586
+ this.connecting = true;
6573
6587
  try {
6574
6588
  await this.loadOverlay();
6589
+ await this.refreshReadiness();
6590
+ if (!this.configured) {
6591
+ this.lastStartError = void 0;
6592
+ this.runningConfig = void 0;
6593
+ this.ctx.logger?.info?.("dsh-channels: 飞书应用凭据未配置完整(缺少 %s),本次不连接飞书;请在 Web 设置面板 → 飞书渠道中配置,保存后会自动连接", this.missingConfigItems.join("、"));
6594
+ return;
6595
+ }
6596
+ await this.openConnection();
6597
+ } catch {} finally {
6598
+ this.connecting = false;
6599
+ }
6600
+ }
6601
+ /**
6602
+ * Refresh the cached configuration readiness from the credentials seam.
6603
+ * Only presence is checked (describe/resolve), never values; the secrets
6604
+ * themselves are resolved by `start()` when a connection is opened.
6605
+ */
6606
+ async refreshReadiness() {
6607
+ const states = await this.credentialStates();
6608
+ const missing = [];
6609
+ if (!states.appId.configured) missing.push(`${this.config.appId}(飞书 App ID)`);
6610
+ if (!states.appSecret.configured) missing.push(`${this.config.appSecret}(飞书 App Secret)`);
6611
+ this.missingConfigItems = missing;
6612
+ this.configured = missing.length === 0;
6613
+ }
6614
+ /**
6615
+ * Open the connection with the live config. A failure tears the partial
6616
+ * service down and records the reason for the panel before rethrowing it to
6617
+ * the caller; the plugin fiber is never failed by a connection problem.
6618
+ */
6619
+ async openConnection() {
6620
+ try {
6575
6621
  await this.start();
6576
6622
  this.lastStartError = void 0;
6577
6623
  this.runningConfig = { ...this.config };
6578
6624
  } catch (error) {
6579
6625
  this.lastStartError = messageOf$3(error);
6626
+ this.ctx.logger?.error?.("dsh-channels: 飞书连接失败:%s", String(error));
6627
+ console.error("[dsh-channels] feishu connection failed:", error);
6580
6628
  try {
6581
6629
  await this.shutdown();
6582
6630
  } catch {}
6583
6631
  throw error;
6584
6632
  }
6585
6633
  }
6634
+ /**
6635
+ * Panel action: open the long connection when the channel is stopped.
6636
+ * Credentials are re-checked first — an incomplete pair throws a message the
6637
+ * panel shows next to the credentials form instead of attempting a connect.
6638
+ */
6639
+ async connect() {
6640
+ if (this.started) return;
6641
+ if (this.connecting || this.restarting) throw new Error("飞书连接正在连接或重启,请稍候再试");
6642
+ this.connecting = true;
6643
+ try {
6644
+ await this.loadOverlay();
6645
+ await this.refreshReadiness();
6646
+ if (!this.configured) throw new Error(`飞书应用凭据尚未配置完整:缺少 ${this.missingConfigItems.join("、")}`);
6647
+ await this.openConnection();
6648
+ } finally {
6649
+ this.connecting = false;
6650
+ }
6651
+ }
6586
6652
  /** Re-read the Web overlay over the profile config (no channel touch). */
6587
6653
  async loadOverlay() {
6588
6654
  const overlay = this.overlay === void 0 ? {} : await this.overlay.load();
@@ -6613,22 +6679,24 @@ var FeishuService = class {
6613
6679
  });
6614
6680
  this.refreshRetentionTimer();
6615
6681
  }
6616
- /** Tear the connection down and bring it back with the latest config. */
6682
+ /**
6683
+ * Tear the connection down and bring it back with the latest config. When the
6684
+ * credentials are no longer complete the channel simply stays stopped (no
6685
+ * error): the panel then shows the configuration guide instead of a failure.
6686
+ */
6617
6687
  async restart() {
6618
- if (this.restarting) throw new Error("飞书连接正在重启,请稍候再试");
6688
+ if (this.restarting || this.connecting) throw new Error("飞书连接正在连接或重启,请稍候再试");
6619
6689
  this.restarting = true;
6620
6690
  try {
6621
6691
  await this.shutdown();
6622
6692
  await this.loadOverlay();
6623
- await this.start();
6624
- this.lastStartError = void 0;
6625
- this.runningConfig = { ...this.config };
6626
- } catch (error) {
6627
- this.lastStartError = messageOf$3(error);
6628
- try {
6629
- await this.shutdown();
6630
- } catch {}
6631
- throw error;
6693
+ await this.refreshReadiness();
6694
+ if (!this.configured) {
6695
+ this.lastStartError = void 0;
6696
+ this.runningConfig = void 0;
6697
+ return;
6698
+ }
6699
+ await this.openConnection();
6632
6700
  } finally {
6633
6701
  this.restarting = false;
6634
6702
  }
@@ -6639,6 +6707,18 @@ var FeishuService = class {
6639
6707
  isRestarting() {
6640
6708
  return this.restarting;
6641
6709
  }
6710
+ /** True while the panel's explicit connect action is in flight. */
6711
+ isConnecting() {
6712
+ return this.connecting;
6713
+ }
6714
+ /** Whether both Feishu application credentials currently resolve. */
6715
+ isConfigured() {
6716
+ return this.configured;
6717
+ }
6718
+ /** Names of the missing configuration items (never values). */
6719
+ missingConfig() {
6720
+ return this.missingConfigItems;
6721
+ }
6642
6722
  startedAtMs() {
6643
6723
  return this.startedAt;
6644
6724
  }
@@ -6662,6 +6742,7 @@ var FeishuService = class {
6662
6742
  const admin = this.config.adminOpenId;
6663
6743
  return {
6664
6744
  mode: this.config.accessMode,
6745
+ loaded: this.domain !== void 0,
6665
6746
  admin: {
6666
6747
  ...admin === void 0 ? {} : { openId: admin },
6667
6748
  configured: admin !== void 0
@@ -6706,7 +6787,7 @@ var FeishuService = class {
6706
6787
  const admin = this.config.adminOpenId;
6707
6788
  if (admin === void 0) return {
6708
6789
  ok: false,
6709
- error: "请先在常用配置中设置管理员 open_id"
6790
+ error: "请先在「访问授权」标签页设置管理员 open_id"
6710
6791
  };
6711
6792
  try {
6712
6793
  if (!this.accessStore.hasGroup(id)) await this.accessStore.addGroup({
@@ -6757,7 +6838,7 @@ var FeishuService = class {
6757
6838
  const admin = this.config.adminOpenId;
6758
6839
  if (admin === void 0) return {
6759
6840
  ok: false,
6760
- error: "请先在常用配置中设置管理员 open_id"
6841
+ error: "请先在「访问授权」标签页设置管理员 open_id"
6761
6842
  };
6762
6843
  try {
6763
6844
  if (!this.accessStore.hasUser(id)) await this.accessStore.addUser({
@@ -6808,7 +6889,7 @@ var FeishuService = class {
6808
6889
  const actor = this.config.adminOpenId;
6809
6890
  if (actor === void 0) return {
6810
6891
  ok: false,
6811
- error: "请先配置管理员 open_id"
6892
+ error: "请先在「访问授权」标签页配置管理员 open_id"
6812
6893
  };
6813
6894
  try {
6814
6895
  const result = await requests.resolve(token, decision, actor);
@@ -6853,6 +6934,13 @@ var FeishuService = class {
6853
6934
  ...window === void 0 ? {} : { thresholdMs: window },
6854
6935
  auto: this.config.workspaceRetentionAuto
6855
6936
  };
6937
+ if (this.domain === void 0) return {
6938
+ ...base,
6939
+ workspaces: 0,
6940
+ candidates: 0,
6941
+ candidateBytes: 0,
6942
+ error: "飞书连接建立后即可预览和清理工作区"
6943
+ };
6856
6944
  try {
6857
6945
  const workspaces = this.ctx.workspaceRegistry.list().filter((workspace) => this.isPluginWorkspace(workspace));
6858
6946
  const plan = await planWorkspaceCleanup(this.cleanupDeps());
@@ -6886,6 +6974,10 @@ var FeishuService = class {
6886
6974
  freedBytes: 0,
6887
6975
  failures: []
6888
6976
  };
6977
+ if (this.domain === void 0) return {
6978
+ ...empty,
6979
+ error: "飞书连接建立后才能清理工作区"
6980
+ };
6889
6981
  if (retentionMs(this.config.workspaceRetention) === void 0) return {
6890
6982
  ...empty,
6891
6983
  error: "未启用工作区清理阈值(workspaceRetention 为 off)"
@@ -7051,6 +7143,7 @@ var FeishuService = class {
7051
7143
  } catch (error) {
7052
7144
  throw new Error(`写入凭据引用 ${ref} 失败:${messageOf$3(error)}`);
7053
7145
  }
7146
+ await this.refreshReadiness();
7054
7147
  }
7055
7148
  /** Remove the stored value of one Feishu credential reference. */
7056
7149
  async clearCredential(field) {
@@ -7062,6 +7155,7 @@ var FeishuService = class {
7062
7155
  } catch (error) {
7063
7156
  throw new Error(`移除凭据引用 ${ref} 失败:${messageOf$3(error)}`);
7064
7157
  }
7158
+ await this.refreshReadiness();
7065
7159
  }
7066
7160
  /** The overlay-independent ref name backing one editable credential slot. */
7067
7161
  credentialRef(field) {
@@ -7136,12 +7230,12 @@ var FeishuService = class {
7136
7230
  answerer.onCardAction(event);
7137
7231
  questions.onCardAction(event);
7138
7232
  });
7139
- this.ctx.on("session/event", (session, event) => {
7233
+ this.unlistenHost.push(this.ctx.on("session/event", (session, event) => {
7140
7234
  this.streamer?.onSessionEvent(session, event);
7141
- });
7142
- this.ctx.on("agent/assistant-stream", ({ agent, frame }) => {
7235
+ }));
7236
+ this.unlistenHost.push(this.ctx.on("agent/assistant-stream", ({ agent, frame }) => {
7143
7237
  this.streamer?.onAssistantFrame(agent.session, frame);
7144
- });
7238
+ }));
7145
7239
  this.unlisten = channel.onMessage((message) => {
7146
7240
  this.inbound.handle(message).catch((error) => {
7147
7241
  this.ctx.logger?.warn?.("dsh-channels inbound error: %s", String(error));
@@ -7198,6 +7292,7 @@ var FeishuService = class {
7198
7292
  this.unlistenApproval = void 0;
7199
7293
  this.unlistenQuestions?.();
7200
7294
  this.unlistenQuestions = void 0;
7295
+ for (const dispose of this.unlistenHost.splice(0)) dispose();
7201
7296
  this.approvalAnswerer?.dispose();
7202
7297
  this.approvalAnswerer = void 0;
7203
7298
  this.questionAnswerer?.dispose();
@@ -8168,6 +8263,9 @@ const FEISHU_LOGGER_LEVELS = [
8168
8263
  ];
8169
8264
  /** Same shape as the host credentials seam's `CredentialRef` (shell identifier). */
8170
8265
  const CREDENTIAL_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
8266
+ /** Conventional credential-reference names used when the loader passes no config. */
8267
+ const DEFAULT_APP_ID_REF = "FEISHU_APP_ID";
8268
+ const DEFAULT_APP_SECRET_REF = "FEISHU_APP_SECRET";
8171
8269
  /** Default route-table GC threshold: 30 days without activity. */
8172
8270
  const DEFAULT_ROUTE_TTL_MS = 720 * 60 * 60 * 1e3;
8173
8271
  /** Default HITL approval decision window: 5 minutes per question. */
@@ -8177,10 +8275,10 @@ const DEFAULT_QUESTION_TIMEOUT_MS = 300 * 1e3;
8177
8275
  /** A credential reference: `FEISHU_APP_ID`-style name resolved through ctx.credentials. */
8178
8276
  const credentialRefSchema = z.string().regex(CREDENTIAL_REF_PATTERN, "must be a credential reference name such as FEISHU_APP_ID (resolved through the dsh credentials seam, e.g. $DSH_HOME/.credentials.yaml) — plaintext secrets are not accepted");
8179
8277
  const configSchema = z.object({
8180
- /** Feishu app id credential reference (e.g. `FEISHU_APP_ID`). */
8181
- appId: credentialRefSchema,
8182
- /** Feishu app secret credential reference (e.g. `FEISHU_APP_SECRET`). */
8183
- appSecret: credentialRefSchema,
8278
+ /** Feishu app id credential reference (defaults to `FEISHU_APP_ID`). */
8279
+ appId: credentialRefSchema.default(DEFAULT_APP_ID_REF),
8280
+ /** Feishu app secret credential reference (defaults to `FEISHU_APP_SECRET`). */
8281
+ appSecret: credentialRefSchema.default(DEFAULT_APP_SECRET_REF),
8184
8282
  /**
8185
8283
  * Root directory for per-(user, chat) workspace directories — the filesystem
8186
8284
  * root where files produced by channel sessions land. Read per session
@@ -8323,6 +8421,14 @@ const configSchema = z.object({
8323
8421
  questionTimeoutMs: z.number().int().min(1e4).max(36e5).default(DEFAULT_QUESTION_TIMEOUT_MS)
8324
8422
  });
8325
8423
  /**
8424
+ * DSH loader-facing schema. The bundle patch inserts this plugin without a
8425
+ * `config` object; the loader validates that row with `undefined` before the
8426
+ * user's patch layer supplies values. Accept that absence and fill the same
8427
+ * defaults as {@link resolveConfig}, so enabling the plugin from the Web UI
8428
+ * cannot fail with "expected object, received undefined".
8429
+ */
8430
+ const pluginConfigSchema = configSchema.prefault({});
8431
+ /**
8326
8432
  * Validate the raw loader config. Values stay credential *references*; the
8327
8433
  * secrets themselves resolve through `ctx.credentials` at startup.
8328
8434
  * @param raw - the config object the cordis loader passed (may be undefined).
@@ -8351,6 +8457,11 @@ const STATUS_ROUTE = "/dsh-channels/status";
8351
8457
  const PANEL_ROUTE = "/dsh-channels/panel";
8352
8458
  const CONFIG_ROUTE = "/dsh-channels/config";
8353
8459
  const CREDENTIALS_ROUTE = "/dsh-channels/credentials";
8460
+ /**
8461
+ * Connection lifecycle action (POST). While the channel runs it restarts the
8462
+ * long connection; while it is stopped it opens one — so the panel has exactly
8463
+ * one "connect / reconnect" button. Credentials are resolved again either way.
8464
+ */
8354
8465
  const RECONNECT_ROUTE = "/dsh-channels/reconnect";
8355
8466
  /** 模型/推理档位/预设选择器的宿主目录(provider → model → effort 级联刷新)。 */
8356
8467
  const CATALOG_ROUTE = "/dsh-channels/catalog";
@@ -8365,6 +8476,14 @@ const ACCESS_USERS_ROUTE = "/dsh-channels/access/users";
8365
8476
  /** 结算一条待处理授权申请(POST,body `{ token, decision }`)。 */
8366
8477
  const ACCESS_REQUESTS_ROUTE = "/dsh-channels/access/requests";
8367
8478
  /**
8479
+ * 打开宿主机的系统目录选择框(POST,无请求体),返回绝对路径。
8480
+ *
8481
+ * 只有宿主挂载了 `directoryPicker` 的 native 后端(桌面/本地 GUI 会话)才
8482
+ * 会注册实际选择逻辑;远程/无头会话的 browse 后端不可用,面板不下发
8483
+ * [`EditableFieldWire.browse`],保留手写输入。
8484
+ */
8485
+ const PICK_DIRECTORY_ROUTE = "/dsh-channels/pick-directory";
8486
+ /**
8368
8487
  * 可由 Web 面板覆盖的配置项。全部是 `src/config.ts` 里已存在的字段;
8369
8488
  * 覆盖值写进 `$DSH_HOME/dsh-channels/settings.json`,优先级高于 profile
8370
8489
  * 的 `cordis.patch.yml`(见 README「Web 设置面板」)。
@@ -8412,22 +8531,55 @@ const RESTART_KEYS = [
8412
8531
  /** Display labels for the panel header/cards (kept on the wire so the client owns no copy). */
8413
8532
  const PANEL_COPY = {
8414
8533
  title: "飞书渠道",
8415
- description: "飞书长连接状态、应用凭据与常用配置。凭据写入 DSH 凭据库且不回显;标注「需重连」的项需重启飞书连接生效。",
8534
+ description: "飞书长连接、应用凭据与渠道配置。凭据只写入 DSH 凭据库且不回显;标注「需重连」的项在连接建立时读取。",
8416
8535
  statusCard: "连接状态",
8536
+ phaseUnconfigured: "待配置",
8537
+ phaseIdle: "未连接",
8538
+ phaseConnecting: "连接中",
8539
+ phaseConnected: "已连接",
8540
+ phaseError: "连接失败",
8541
+ configuredBadge: "凭据已配置",
8542
+ unconfiguredBadge: "凭据未配置",
8543
+ statusSummaryConnected: "飞书长连接已建立,机器人可以收发消息。",
8544
+ statusSummaryUnconfigured: "尚未配置飞书应用凭据:插件已加载,但不会连接飞书。填写凭据并保存后会自动连接。",
8545
+ statusSummaryIdle: "飞书应用凭据已就绪,但当前没有建立长连接。",
8546
+ statusSummaryConnecting: "正在连接飞书长连接…",
8547
+ statusSummaryError: "最近一次连接飞书失败,请检查凭据与网络后重试。",
8548
+ lastError: "最近一次连接失败",
8549
+ missingConfig: "缺少配置",
8550
+ statusHintUnconfigured: "配置完成后插件会自动连接;也可以稍后在本页手动连接。",
8551
+ connectionInfo: "连接信息",
8552
+ connectionStateLabel: "飞书长连接",
8553
+ botOpenId: "Bot open_id",
8554
+ uptime: "运行时长",
8555
+ routeCount: "话题路由",
8556
+ liveAgentCount: "活动会话",
8557
+ inflightTurnCount: "在途回复",
8558
+ healthReasons: "健康判定",
8559
+ unknownValue: "—",
8560
+ connect: "连接飞书",
8561
+ connecting: "连接中…",
8562
+ onboardingTitle: "开始使用飞书渠道",
8563
+ onboardingIntro: "只需三步:在飞书开放平台创建自建应用,把 App ID / App Secret 填入本页,保存后自动连接。",
8564
+ onboardingStep1Title: "创建飞书自建应用",
8565
+ onboardingStep1Text: "在飞书开放平台创建企业自建应用,开启机器人能力;事件订阅选择「长连接」,订阅 im.message.receive_v1(接收消息)。",
8566
+ onboardingStep2Title: "填写应用凭据",
8567
+ onboardingStep2Text: "把应用的 App ID(cli_ 开头)与 App Secret 填入下方「飞书应用凭据」,值会写入 DSH 凭据库。",
8568
+ onboardingStep3Title: "保存并等待连接",
8569
+ onboardingStep3Text: "两项凭据都保存后插件会自动连接;状态变为「已连接」即可在飞书里私聊机器人。",
8570
+ onboardingDocsHint: "开放平台地址:open.feishu.cn/app;应用需要开通 im:message、im:message:send_as_bot 等消息权限。",
8417
8571
  credentialsCard: "飞书应用凭据",
8418
- modelCard: "模型与推理",
8419
- modelCardHelp: "模型服务商、模型 id、推理档位与新会话的 Agent 预设。下拉选项来自宿主机实时目录;保存后对新会话生效。",
8420
- configCard: "常用配置",
8421
- advancedCard: "只读与高级信息",
8572
+ credentialsCardHelp: "凭据值经 DSH 凭据服务写入本机凭据库;面板只显示「已配置 / 未配置」,永不回显已存值。两项都配置后才会连接飞书。",
8422
8573
  appIdLabel: "飞书 App ID",
8423
8574
  appSecretLabel: "飞书 App Secret",
8424
- credentialsHelp: "值通过 DSH 凭据服务写入本机凭据库;面板只显示是否已配置,永不回显已存值。留空表示保持当前值。",
8425
8575
  credentialsRef: "凭据引用",
8426
8576
  saveCredentials: "保存凭据",
8427
8577
  savingCredentials: "保存中…",
8428
8578
  credentialSaved: "凭据已保存",
8429
- credentialSavedReconnected: "凭据已保存,已自动重连飞书通道",
8579
+ credentialSavedReconnected: "凭据已保存,飞书已连接",
8430
8580
  credentialSavedPending: "凭据已保存,重启飞书连接后生效",
8581
+ credentialSavedUnconfigured: "凭据已保存;还缺少另一项凭据,补齐后会自动连接",
8582
+ credentialAutoConnectFailed: "凭据已保存,但自动连接失败",
8431
8583
  credentialRemoved: "已移除存储的凭据",
8432
8584
  credentialRemovedPending: "已移除存储的凭据,重启飞书连接后生效",
8433
8585
  removeCredential: "移除已存值",
@@ -8437,35 +8589,56 @@ const PANEL_COPY = {
8437
8589
  credentialReadOnly: "当前凭据存储不可写",
8438
8590
  appIdPlaceholder: "输入 App ID(cli_…)",
8439
8591
  appSecretPlaceholder: "输入 App Secret",
8440
- save: "保存配置",
8441
- saveAll: "保存全部配置",
8442
- saveModel: "保存模型配置",
8443
- modelActionsHint: "只保存模型与推理字段",
8592
+ credentialsPendingHint: "填写后点「保存凭据」;两项齐全时插件会自动连接飞书。",
8593
+ connectionTabHelp: "连接状态与飞书应用凭据。凭据不完整时不会连接飞书。",
8594
+ sessionsTabHelp: "私聊、群聊触发方式与入站内容;「需重连」项在下次连接时读取。",
8595
+ modelTabHelp: "飞书会话使用的模型服务商、模型、推理档位与 Agent 预设;下拉选项来自宿主机实时目录,保存后对新会话生效。",
8596
+ workspaceTabHelp: "每个飞书话题一个独立工作区(模型/工具产生文件的落点);只影响之后新建的话题。",
8597
+ accessTabHelp: "准入采用 fail-closed:管理员不受白名单限制,非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。",
8598
+ advancedTabHelp: "流式刷新等低层参数、Web 覆盖文件与只读信息。",
8599
+ save: "保存更改",
8444
8600
  saving: "保存中…",
8601
+ saveHintDirty: "只保存改动过的字段;标注「需重连」的项在下次连接时生效。",
8602
+ discardTab: "放弃本页更改",
8603
+ saveHintClean: "没有未保存的更改。",
8604
+ dirtyCount: "未保存项",
8605
+ browseDirectory: "选择目录…",
8606
+ browsingDirectory: "选择中…",
8607
+ directoryPicked: "已选择目录",
8608
+ directoryPickFailed: "无法打开系统目录选择器",
8609
+ saved: "已保存",
8610
+ cleared: "已清除 Web 覆盖,恢复为 profile 配置",
8445
8611
  clear: "清除 Web 覆盖",
8446
8612
  clearing: "清除中…",
8613
+ noOverrides: "当前没有 Web 覆盖",
8614
+ overridden: "Web 覆盖",
8615
+ inherited: "继承 profile",
8616
+ restartTag: "需重连",
8617
+ restartBanner: "以下配置需重启飞书连接后生效:",
8618
+ restartWarning: "重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。",
8447
8619
  reconnect: "重启飞书连接",
8448
8620
  reconnecting: "重连中…",
8449
8621
  refresh: "刷新状态",
8450
8622
  retry: "重试",
8623
+ pollFailed: "状态刷新失败",
8624
+ connectDone: "已连接飞书",
8625
+ reconnectDone: "已重启飞书连接",
8626
+ saveInvalid: "请先修正标红的配置项",
8627
+ enabled: "已启用",
8628
+ disabled: "已关闭",
8629
+ credentialRemoveConfirm: "确定移除已存储的凭据值?移除后不会回显,需要时须重新填写。",
8630
+ accessNotLoaded: "飞书连接建立后加载授权列表",
8451
8631
  loading: "加载中…",
8452
8632
  loadFailed: "设置面板加载失败",
8453
- saved: "已保存",
8454
- savedModel: "模型配置已保存",
8455
- cleared: "已清除 Web 覆盖,恢复为 profile 配置",
8456
8633
  catalogFailed: "模型目录加载失败,已退回手填。",
8457
8634
  catalogLoading: "正在读取宿主机选项…",
8458
8635
  catalogUnavailable: "宿主机未提供可选项,请手填。",
8459
- noOverrides: "当前没有 Web 覆盖",
8460
- overridden: "Web 覆盖",
8461
- inherited: "继承 profile",
8462
- restartTag: "需重连",
8463
- restartBanner: "以下配置需重启飞书连接后生效:",
8464
- restartWarning: "重启会断开当前飞书长连接并中断进行中的回复,请确认空闲时操作。",
8465
- lastError: "最近一次启动失败",
8466
- notRunning: "未运行",
8467
8636
  version: "插件版本",
8468
8637
  overlayPath: "覆盖文件",
8638
+ overlayPathHelp: "可以直接编辑该 JSON 文件;面板保存会整体重写它,「清除 Web 覆盖」会删除它并回落到 profile 配置。",
8639
+ advancedCard: "只读与高级信息",
8640
+ advancedHelp: "这些值来自 profile 配置,不能在面板里编辑。",
8641
+ readOnlyCard: "部署级参数",
8469
8642
  cleanupCard: "工作区清理",
8470
8643
  cleanupCardHelp: "删除超过保留期且不再活跃的飞书工作区目录;只会删除本插件在「工作区根目录」下创建的目录,DSH 会话日志不删除。删除不可恢复:相关旧话题会失效并提示已过期,工作区列表记录同步移除。",
8471
8644
  cleanupRetention: "保留期",
@@ -8480,7 +8653,7 @@ const PANEL_COPY = {
8480
8653
  cleanupRunning: "清理中…",
8481
8654
  cleanupConfirm: "确定清理过期工作区吗?该操作不可恢复,会删除这些工作区内的全部文件(包括 attachments/ 附件)。",
8482
8655
  cleanupConfirmHint: "删除前会再次确认;保留期为「关闭」或没有过期工作区时按钮不可用。",
8483
- cleanupDisabledOff: "保留策略为「关闭」:请先在「常用配置 → 工作区与文件」中选择保留天数。",
8656
+ cleanupDisabledOff: "保留策略为「关闭」:请先在上方「过期工作区保留期」中选择保留天数。",
8484
8657
  cleanupDisabledEmpty: "没有超过保留期的过期工作区。",
8485
8658
  cleanupPreviewFailed: "无法读取清理预览",
8486
8659
  cleanupDone: "清理完成",
@@ -8490,12 +8663,9 @@ const PANEL_COPY = {
8490
8663
  cleanupFreed: "释放空间",
8491
8664
  cleanupFailures: "以下工作区未能删除:",
8492
8665
  accessCard: "访问与授权",
8493
- accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员 open_id 在「常用配置 → 行为」中填写,私聊机器人发送 /userid 获取。",
8666
+ accessCardHelp: "准入采用 fail-closed:管理员不受白名单限制(私聊、任意群聊都可直接对话);非管理员私聊会被拒绝,群聊需要「群已授权 + 用户在白名单」两层同时命中。管理员与准入模式在上方「准入与授权」中编辑;私聊机器人发送 /userid 获取自己的 open_id。",
8494
8667
  accessModeOpenWarning: "当前准入模式为开放(open):应用可用范围内的任何人都能开启对话。该模式不安全,仅建议调试时使用。",
8495
- accessAdminMissingWarning: "当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请先在「常用配置 → 行为 → 管理员 open_id」填写。",
8496
- accessAdminLabel: "管理员",
8497
- accessAdminMissing: "未配置",
8498
- accessAdminHint: "修改管理员请到「常用配置 → 行为 → 管理员 open_id」;私聊机器人发送 /userid 可查看自己的 open_id(ou_ 开头,不是 user_id/邮箱)。",
8668
+ accessAdminMissingWarning: "当前准入=guarded 但未配置管理员,所有对话都会被拒绝。请在上方「准入与授权」填写管理员 open_id。",
8499
8669
  accessGroupsTitle: "已授权群",
8500
8670
  accessGroupsEmpty: "尚未授权任何群。机器人被拉进群后,管理员会收到带 chat_id 的群信息卡;也可在群内发送 /groupid 获取。",
8501
8671
  accessUsersTitle: "已授权用户",
@@ -8951,7 +9121,7 @@ function registerSettingsRoutes(server, surface) {
8951
9121
  return;
8952
9122
  }
8953
9123
  const result = await surface.reconnect();
8954
- sendJson(res, result.ok ? 200 : 500, result);
9124
+ sendJson(res, result.ok ? 200 : result.unconfigured === true ? 400 : 500, result);
8955
9125
  })
8956
9126
  }),
8957
9127
  server.register({
@@ -8967,6 +9137,41 @@ function registerSettingsRoutes(server, surface) {
8967
9137
  sendJson(res, result.ok ? 200 : 500, result);
8968
9138
  })
8969
9139
  }),
9140
+ server.register({
9141
+ kind: "exact",
9142
+ path: PICK_DIRECTORY_ROUTE,
9143
+ handler: guarded(async (req, res) => {
9144
+ if (!allowOnly(req, res, ["POST"])) return;
9145
+ if (!isSameOriginRequest(req)) {
9146
+ forbiddenWrite(res);
9147
+ return;
9148
+ }
9149
+ const controller = new AbortController();
9150
+ let answered = false;
9151
+ if (typeof res.on === "function") res.on("close", () => {
9152
+ if (!answered) controller.abort();
9153
+ });
9154
+ let path;
9155
+ try {
9156
+ path = await surface.pickDirectory(controller.signal);
9157
+ } catch (error) {
9158
+ answered = true;
9159
+ if (controller.signal.aborted) {
9160
+ res.end();
9161
+ return;
9162
+ }
9163
+ throw error;
9164
+ }
9165
+ answered = true;
9166
+ sendJson(res, 200, path === null ? {
9167
+ ok: true,
9168
+ canceled: true
9169
+ } : {
9170
+ ok: true,
9171
+ path
9172
+ });
9173
+ })
9174
+ }),
8970
9175
  server.register({
8971
9176
  kind: "exact",
8972
9177
  path: ACCESS_ROUTE,
@@ -9180,7 +9385,7 @@ const FIELD_META = {
9180
9385
  },
9181
9386
  authRequestTtlDays: {
9182
9387
  label: "授权申请有效期(天)",
9183
- help: "入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可直接在「访问与授权」面板处理)。",
9388
+ help: "入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可在本页下方「待处理申请」中处理)。",
9184
9389
  min: 1,
9185
9390
  max: 30,
9186
9391
  step: 1
@@ -9245,9 +9450,9 @@ const GROUP_OF = {
9245
9450
  threadFallback: "behavior",
9246
9451
  welcomeOnboarding: "behavior",
9247
9452
  allowPermissionSwitch: "behavior",
9248
- adminOpenId: "behavior",
9249
- accessMode: "behavior",
9250
- authRequestTtlDays: "behavior",
9453
+ adminOpenId: "access",
9454
+ accessMode: "access",
9455
+ authRequestTtlDays: "access",
9251
9456
  acceptAttachments: "content",
9252
9457
  provider: "model",
9253
9458
  model: "model",
@@ -9343,6 +9548,22 @@ function withCurrentOption(options, value) {
9343
9548
  ...rest
9344
9549
  ];
9345
9550
  }
9551
+ /**
9552
+ * Mark the workspace-root field as browsable when the host can open its native
9553
+ * directory chooser. Every other host (no `directoryPicker`, or the `browse`
9554
+ * backend of remote/headless sessions) keeps the plain text input.
9555
+ *
9556
+ * @param fields - Host field descriptors after the selector catalog is applied.
9557
+ * @param available - whether a native directory chooser can be opened.
9558
+ * @returns the descriptors with `browse: 'directory'` on `baseDir` when usable.
9559
+ */
9560
+ function withDirectoryBrowse(fields, available) {
9561
+ if (!available) return [...fields];
9562
+ return fields.map((field) => field.key === "baseDir" ? {
9563
+ ...field,
9564
+ browse: "directory"
9565
+ } : field);
9566
+ }
9346
9567
  function messageOf$1(error) {
9347
9568
  return error instanceof Error ? error.message : String(error);
9348
9569
  }
@@ -9467,12 +9688,28 @@ function readOnlyFields(config) {
9467
9688
  }
9468
9689
  ];
9469
9690
  }
9691
+ /**
9692
+ * Derive the panel phase from readiness + runtime facts. The order matters:
9693
+ * an incomplete configuration always wins (no connection is attempted), and a
9694
+ * failed attempt is only reported while the channel is stopped.
9695
+ */
9696
+ function phaseOf(target) {
9697
+ if (target.isRestarting()) return "connecting";
9698
+ if (target.isConnecting()) return target.isConfigured() ? "connecting" : "unconfigured";
9699
+ if (target.isRunning()) return "connected";
9700
+ if (!target.isConfigured()) return "unconfigured";
9701
+ if (target.startError() !== void 0) return "error";
9702
+ return "idle";
9703
+ }
9470
9704
  /** Assemble the status half of the panel payload. */
9471
9705
  function buildStatus(target, overlayPath) {
9472
9706
  const health = target.health();
9473
9707
  const connection = health.connection;
9474
9708
  return {
9475
9709
  running: target.isRunning(),
9710
+ configured: target.isConfigured(),
9711
+ phase: phaseOf(target),
9712
+ missingConfig: [...target.missingConfig()],
9476
9713
  level: health.level,
9477
9714
  startedAt: target.startedAtMs(),
9478
9715
  uptimeMs: target.uptimeMs(),
@@ -9490,7 +9727,7 @@ function buildStatus(target, overlayPath) {
9490
9727
  reasons: health.reasons,
9491
9728
  version: DSH_CHANNELS_VERSION,
9492
9729
  overlayPath,
9493
- busy: target.isRestarting()
9730
+ busy: target.isRestarting() || target.isConnecting()
9494
9731
  };
9495
9732
  }
9496
9733
  /**
@@ -9505,14 +9742,41 @@ var SettingsSurface = class {
9505
9742
  store;
9506
9743
  logger;
9507
9744
  catalog;
9508
- constructor(target, store, logger, catalog) {
9745
+ directoryPicker;
9746
+ constructor(target, store, logger, catalog, directoryPicker) {
9509
9747
  this.target = target;
9510
9748
  this.store = store;
9511
9749
  this.logger = logger;
9512
9750
  this.catalog = catalog;
9751
+ this.directoryPicker = directoryPicker;
9752
+ }
9753
+ /**
9754
+ * Re-read credential presence. The credentials store is hot-reloaded from
9755
+ * disk, so a value edited outside the panel becomes visible within one poll
9756
+ * instead of only after a restart/connect.
9757
+ */
9758
+ async syncReadiness() {
9759
+ try {
9760
+ await this.target.refreshReadiness();
9761
+ } catch (error) {
9762
+ this.logger?.warn?.("dsh-channels: 凭据状态刷新失败:%s", messageOf$1(error));
9763
+ }
9764
+ }
9765
+ /**
9766
+ * Reject panel writes while the connection lifecycle is in flight: saving an
9767
+ * overlay or credentials then would race `applyOverlay()`/`start()` and can
9768
+ * record a running-config snapshot that never took effect.
9769
+ */
9770
+ writeBusy() {
9771
+ if (!this.target.isRestarting() && !this.target.isConnecting()) return void 0;
9772
+ return {
9773
+ ok: false,
9774
+ error: "飞书连接正在连接或重启,请稍候再试"
9775
+ };
9513
9776
  }
9514
9777
  /** Wire status only (used by the client's 5 s poll). */
9515
9778
  async status() {
9779
+ await this.syncReadiness();
9516
9780
  return buildStatus(this.target, this.store.file);
9517
9781
  }
9518
9782
  /**
@@ -9531,6 +9795,7 @@ var SettingsSurface = class {
9531
9795
  }
9532
9796
  /** Status + editable config with provenance + read-only facts. */
9533
9797
  async panel() {
9798
+ await this.syncReadiness();
9534
9799
  const base = this.target.baseConfigSnapshot();
9535
9800
  const effective = this.target.configSnapshot();
9536
9801
  const overlay = await this.store.load();
@@ -9538,12 +9803,12 @@ var SettingsSurface = class {
9538
9803
  const overriddenKeys = EDITABLE_KEYS.filter((key) => overlay[key] !== void 0);
9539
9804
  const restartPendingKeys = this.target.isRunning() && running !== void 0 ? RESTART_KEYS.filter((key) => effective[key] !== running[key]) : [];
9540
9805
  const catalog = await this.catalogFor(effective.provider, effective.model);
9541
- const fields = applyCatalog(FIELD_DESCRIPTORS, catalog, {
9806
+ const fields = withDirectoryBrowse(applyCatalog(FIELD_DESCRIPTORS, catalog, {
9542
9807
  provider: effective.provider,
9543
9808
  model: effective.model,
9544
9809
  reasoningEffort: effective.reasoningEffort,
9545
9810
  agentPreset: effective.agentPreset
9546
- });
9811
+ }), this.directoryPicker?.available === true);
9547
9812
  let credentialStates = {
9548
9813
  appId: {
9549
9814
  configured: false,
@@ -9591,6 +9856,7 @@ var SettingsSurface = class {
9591
9856
  access = {
9592
9857
  mode: effective.accessMode,
9593
9858
  admin: { configured: false },
9859
+ loaded: false,
9594
9860
  groups: [],
9595
9861
  users: [],
9596
9862
  requests: []
@@ -9614,6 +9880,8 @@ var SettingsSurface = class {
9614
9880
  }
9615
9881
  /** Validate + persist a partial edit; live fields take effect immediately. */
9616
9882
  async save(values) {
9883
+ const busy = this.writeBusy();
9884
+ if (busy !== void 0) return busy;
9617
9885
  const unknown = Object.keys(values).filter((key) => !EDITABLE_KEYS.includes(key));
9618
9886
  if (unknown.length > 0) return {
9619
9887
  ok: false,
@@ -9696,6 +9964,8 @@ var SettingsSurface = class {
9696
9964
  }
9697
9965
  /** Drop every Web override and fall back to the profile config. */
9698
9966
  async clear() {
9967
+ const busy = this.writeBusy();
9968
+ if (busy !== void 0) return busy;
9699
9969
  try {
9700
9970
  await this.store.clear();
9701
9971
  } catch (error) {
@@ -9712,6 +9982,8 @@ var SettingsSurface = class {
9712
9982
  }
9713
9983
  /** Write provided credential values through the host seam (never echoed back). */
9714
9984
  async saveCredentials(values) {
9985
+ const busy = this.writeBusy();
9986
+ if (busy !== void 0) return busy;
9715
9987
  const writes = [];
9716
9988
  const issues = [];
9717
9989
  for (const field of ["appId", "appSecret"]) {
@@ -9751,6 +10023,8 @@ var SettingsSurface = class {
9751
10023
  }
9752
10024
  /** Remove the stored value behind one credential reference. */
9753
10025
  async clearCredential(field) {
10026
+ const busy = this.writeBusy();
10027
+ if (busy !== void 0) return busy;
9754
10028
  if (field !== "appId" && field !== "appSecret") return {
9755
10029
  ok: false,
9756
10030
  error: "未知的凭据字段"
@@ -9766,9 +10040,11 @@ var SettingsSurface = class {
9766
10040
  return this.finishCredentialChange("clear");
9767
10041
  }
9768
10042
  /**
9769
- * Credential values are resolved on connect. When the channel is stopped,
9770
- * retry immediately (nothing to interrupt); when it is running, ask the user
9771
- * to reconnect. A failed retry still reports the save as successful.
10043
+ * Credential values are resolved on connect. Opening a connection is gated on
10044
+ * readiness: saving one half of the pair stores the value but keeps the
10045
+ * plugin idle until the other half exists. When the channel is stopped and
10046
+ * the pair is complete, connect immediately (nothing to interrupt); when it
10047
+ * is running, the user reconnects to pick the value up.
9772
10048
  */
9773
10049
  async finishCredentialChange(kind) {
9774
10050
  if (this.target.isRunning()) return {
@@ -9776,13 +10052,13 @@ var SettingsSurface = class {
9776
10052
  panel: await this.panel(),
9777
10053
  reconnect: "pending"
9778
10054
  };
9779
- if (kind === "clear") return {
10055
+ if (kind === "clear" || !this.target.isConfigured()) return {
9780
10056
  ok: true,
9781
10057
  panel: await this.panel(),
9782
- reconnect: "not-needed"
10058
+ reconnect: kind === "clear" ? "not-needed" : "unconfigured"
9783
10059
  };
9784
10060
  try {
9785
- await this.target.restart();
10061
+ await this.target.connect();
9786
10062
  return {
9787
10063
  ok: true,
9788
10064
  panel: await this.panel(),
@@ -9792,21 +10068,39 @@ var SettingsSurface = class {
9792
10068
  return {
9793
10069
  ok: true,
9794
10070
  panel: await this.panel(),
9795
- reconnect: "auto",
10071
+ reconnect: "failed",
9796
10072
  autoReconnectError: messageOf$1(error)
9797
10073
  };
9798
10074
  }
9799
10075
  }
9800
- /** Close and reopen the Feishu long connection with the latest config. */
10076
+ /**
10077
+ * The panel's single connect/reconnect action: reopen a running connection,
10078
+ * or open the first one when the channel is stopped. Credentials must be
10079
+ * complete — otherwise the panel points the user back at the credentials form
10080
+ * instead of surfacing a startup error.
10081
+ */
9801
10082
  async reconnect() {
10083
+ await this.syncReadiness();
10084
+ if (!this.target.isConfigured()) return {
10085
+ ok: false,
10086
+ error: "飞书应用凭据尚未配置完整,请先填写 App ID 与 App Secret 并保存",
10087
+ unconfigured: true
10088
+ };
10089
+ const wasRunning = this.target.isRunning();
9802
10090
  try {
9803
- await this.target.restart();
10091
+ if (wasRunning) await this.target.restart();
10092
+ else await this.target.connect();
9804
10093
  } catch (error) {
9805
10094
  return {
9806
10095
  ok: false,
9807
- error: `重连失败:${messageOf$1(error)}`
10096
+ error: `${wasRunning ? "重连" : "连接"}失败:${messageOf$1(error)}`
9808
10097
  };
9809
10098
  }
10099
+ if (!this.target.isRunning()) return {
10100
+ ok: false,
10101
+ unconfigured: !this.target.isConfigured(),
10102
+ error: this.target.startError() ?? (this.target.isConfigured() ? "飞书连接未建立" : "飞书应用凭据尚未配置完整,请先填写并保存凭据")
10103
+ };
9810
10104
  return {
9811
10105
  ok: true,
9812
10106
  panel: await this.panel()
@@ -9890,11 +10184,24 @@ var SettingsSurface = class {
9890
10184
  }
9891
10185
  }
9892
10186
  /**
10187
+ * Open the host's native directory chooser (the panel's「选择目录…」button).
10188
+ *
10189
+ * @param signal - caller lifetime; abort closes the OS dialog.
10190
+ * @returns the chosen absolute path, or `null` when the operator cancels.
10191
+ * @throws when the host provides no native directory chooser.
10192
+ */
10193
+ async pickDirectory(signal) {
10194
+ if (this.directoryPicker === void 0) throw new Error(PANEL_COPY.directoryPickFailed);
10195
+ return await this.directoryPicker.pick(signal);
10196
+ }
10197
+ /**
9893
10198
  * Run the workspace-retention cleanup once. Irreversible deletions are the
9894
10199
  * target's responsibility; the surface only reports the outcome and returns
9895
10200
  * a refreshed panel (its cleanup preview then reflects the remaining workspaces).
9896
10201
  */
9897
10202
  async runCleanup() {
10203
+ const busy = this.writeBusy();
10204
+ if (busy !== void 0) return busy;
9898
10205
  let result;
9899
10206
  try {
9900
10207
  result = await this.target.runWorkspaceCleanup();
@@ -10158,6 +10465,68 @@ function createHostCatalog(ctx, warn) {
10158
10465
  } };
10159
10466
  }
10160
10467
  //#endregion
10468
+ //#region src/settings/directory-picker.ts
10469
+ /** 取出 native 能力;服务缺失、后端为 browse 或 capability 抛错都返回 undefined。 */
10470
+ function nativeCapability(service) {
10471
+ if (service === void 0 || typeof service.capability !== "function") return void 0;
10472
+ let capability;
10473
+ try {
10474
+ capability = service.capability();
10475
+ } catch {
10476
+ return;
10477
+ }
10478
+ if (typeof capability !== "object" || capability === null) return void 0;
10479
+ const record = capability;
10480
+ if (record.kind !== "native" || typeof record.pick !== "function") return void 0;
10481
+ return capability;
10482
+ }
10483
+ /**
10484
+ * 从宿主上下文取出可用的系统目录选择器。
10485
+ *
10486
+ * 解析是**惰性**的:插件可能先于 `dsh-web-app` 的 directory-picker 行完成
10487
+ * apply,而设置面板只会在宿主全部加载完成后被请求。因此 `available` 与
10488
+ * `pick()` 每次都按需解析(成功结果缓存;失败不缓存,晚挂载的服务仍能被
10489
+ * 发现),避免把加载时序固化成「永远没有按钮」。
10490
+ *
10491
+ * @param ctx - 宿主上下文;`ctx.get('directoryPicker')` 是可选服务。
10492
+ * @returns picker;`available` 为 false 时面板保留手写输入。
10493
+ */
10494
+ function createSettingsDirectoryPicker(ctx) {
10495
+ let resolved;
10496
+ const resolve = () => {
10497
+ if (resolved !== void 0) return resolved;
10498
+ try {
10499
+ const service = ctx.get?.("directoryPicker");
10500
+ resolved = nativeCapability(service);
10501
+ } catch {
10502
+ resolved = void 0;
10503
+ }
10504
+ return resolved;
10505
+ };
10506
+ /** 互斥:一个原生模态框同时只应存在一个(双击/多标签页不得叠窗)。 */
10507
+ let picking = false;
10508
+ return {
10509
+ get available() {
10510
+ return resolve() !== void 0;
10511
+ },
10512
+ async pick(signal) {
10513
+ const capability = resolve();
10514
+ if (capability === void 0) throw new Error("当前宿主机不提供系统目录选择器");
10515
+ if (picking) throw new Error("已有目录选择窗口正在打开");
10516
+ if (signal.aborted) return null;
10517
+ picking = true;
10518
+ try {
10519
+ const picked = await capability.pick.call(capability, signal);
10520
+ if (picked === null || picked === void 0) return null;
10521
+ if (typeof picked !== "string" || picked.trim() === "") throw new Error("目录选择器返回了无效路径");
10522
+ return picked;
10523
+ } finally {
10524
+ picking = false;
10525
+ }
10526
+ }
10527
+ };
10528
+ }
10529
+ //#endregion
10161
10530
  //#region src/index.ts
10162
10531
  /** Cordis function-plugin name. */
10163
10532
  const name = "channels";
@@ -10188,7 +10557,7 @@ const inject = [
10188
10557
  * its `resolveConfig()` fallback for direct invocation (unit tests, embedded
10189
10558
  * hosts) where config has not been pre-validated.
10190
10559
  */
10191
- const Config = configSchema;
10560
+ const Config = pluginConfigSchema;
10192
10561
  /**
10193
10562
  * Install the bridge. Credentials stay references here — the actual secrets
10194
10563
  * resolve through `ctx.credentials` (env > `$DSH_HOME/.credentials.yaml`
@@ -10202,16 +10571,16 @@ function apply(ctx, config) {
10202
10571
  const warn = (message, ...args) => {
10203
10572
  ctx.logger?.warn?.(message, ...args);
10204
10573
  };
10205
- mountSettingsRoutes(ctx, new SettingsSurface(service, overlay, { warn }, createHostCatalog(ctx, warn)));
10574
+ mountSettingsRoutes(ctx, new SettingsSurface(service, overlay, { warn }, createHostCatalog(ctx, warn), createSettingsDirectoryPicker(ctx)));
10206
10575
  ctx.effect(() => {
10207
10576
  service.boot().catch((error) => {
10208
- ctx.logger?.error?.("dsh-channels failed to start: %s", String(error));
10209
- console.error("[dsh-channels] failed to start:", error);
10577
+ ctx.logger?.error?.("dsh-channels failed to boot: %s", String(error));
10578
+ console.error("[dsh-channels] failed to boot:", error);
10210
10579
  });
10211
10580
  return () => service.shutdown();
10212
10581
  }, "dsh-channels");
10213
10582
  }
10214
10583
  //#endregion
10215
- export { AccessController, AccessRequests, AccessStore, ApprovalAnswerer, CARD_MAX_BYTES, CARD_PANEL_RESERVE_BYTES, CARD_TARGET_BYTES, CATALOG_ROUTE, CHANNEL_COMMAND_NAMES, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, Config, DM_DENY_TEXT, DM_UNCONFIGURED_TEXT, DSH_CHANNELS_VERSION, EDITABLE_KEYS, EMPTY_TOTALS, FIELD_DESCRIPTORS, FeishuService, HEALTH_DEPENDENCY_NAMES, INTERRUPTION_NOTICE, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, MAX_QUESTION_OPTIONS, MAX_TABLES_PER_CARD, OverlayStore, PANEL_CHEVRON_TOKEN, PANEL_COPY, PANEL_ROUTE, PANEL_TITLES, QuestionAnswerer, REASONING_MAX_BYTES, RECONNECT_ROUTE, RESTART_KEYS, RouteOperationQueue, SESSION_HISTORY_DISPLAY_LIMIT, STATUS_ROUTE, SUPPORTED_COMMAND_NAMES, SettingsSurface, TRUNCATION_SUFFIX, TurnStreamer, accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, addUsage, appendSessionHistory, apply, applyCatalog, applyProjectionViews, armModelCommand, averageDecodeTokensPerSecond, averageTtftMs, buildApprovalCard, buildApprovalSettledCard, buildCard, buildCommandResultCard, buildCompactComparisonCard, buildGroupInfoCard, buildHealthCard, buildInterruptionCard, buildQuestionCard, buildQuestionSettledCard, buildRenewCard, buildRequestSettledCard, buildSessionHistoryCard, buildSessionStatusCard, buildUserRequestCard, buildWorkDirCard, cacheHitPct, cardJsonBytes, chatRouteKey, commandResultCardKind, compactFailureContext, countFiberStates, createHostCatalog, createLogicalSessionExport, decideAccess, defaultOverlayPath, emptyCatalog, ensureWorkspace, expiredRouteRecordSchema, feishuDomain, foldAll, foldEvent, foldHealthSnapshot, foldSessionStats, inflightSnapshotSchema, inject, isAdminOpenId, isSameOriginRequest, isSupportedCommand, maskOpenIdTail, measureSessionContext, mergeOverlay, mountSettingsRoutes, name, normalizeCredentialValue, normalizeOverlay, normalizeRouteRecord, normalizeSessionHistory, parseOverlay, parseSlashCommand, pickEditable, processedRecordSchema, querySessionHistory, readOnlyFields, readProjectionViews, registerChannelCommands, registerModelCommand, registerSettingsRoutes, resolveDshHome, resolveWorkDir, routeRecordSchema, segmentCardContent, serializeLogicalSession, sessionHistoryEntrySchema, splitBodyForCard, stagingPathFor, stripTables, supportedCommandHelp, supportedCommandList, tableBlocks, topicTokenFor, usageFromBuckets, workspacePathFor, workspaceTitleFor };
10584
+ export { AccessController, AccessRequests, AccessStore, ApprovalAnswerer, CARD_MAX_BYTES, CARD_PANEL_RESERVE_BYTES, CARD_TARGET_BYTES, CATALOG_ROUTE, CHANNEL_COMMAND_NAMES, CLEANUP_ROUTE, CONFIG_ROUTE, CREDENTIALS_ROUTE, Config, DM_DENY_TEXT, DM_UNCONFIGURED_TEXT, DSH_CHANNELS_VERSION, EDITABLE_KEYS, EMPTY_TOTALS, FIELD_DESCRIPTORS, FeishuService, HEALTH_DEPENDENCY_NAMES, INTERRUPTION_NOTICE, LOGICAL_EXPORT_FORMAT, LOGICAL_EXPORT_FORMAT_VERSION, MAX_QUESTION_OPTIONS, MAX_TABLES_PER_CARD, OverlayStore, PANEL_CHEVRON_TOKEN, PANEL_COPY, PANEL_ROUTE, PANEL_TITLES, PICK_DIRECTORY_ROUTE, QuestionAnswerer, REASONING_MAX_BYTES, RECONNECT_ROUTE, RESTART_KEYS, RouteOperationQueue, SESSION_HISTORY_DISPLAY_LIMIT, STATUS_ROUTE, SUPPORTED_COMMAND_NAMES, SettingsSurface, TRUNCATION_SUFFIX, TurnStreamer, accessGroupRecordSchema, accessRequestRecordSchema, accessUserRecordSchema, addUsage, appendSessionHistory, apply, applyCatalog, applyProjectionViews, armModelCommand, averageDecodeTokensPerSecond, averageTtftMs, buildApprovalCard, buildApprovalSettledCard, buildCard, buildCommandResultCard, buildCompactComparisonCard, buildGroupInfoCard, buildHealthCard, buildInterruptionCard, buildQuestionCard, buildQuestionSettledCard, buildRenewCard, buildRequestSettledCard, buildSessionHistoryCard, buildSessionStatusCard, buildUserRequestCard, buildWorkDirCard, cacheHitPct, cardJsonBytes, chatRouteKey, commandResultCardKind, compactFailureContext, countFiberStates, createHostCatalog, createLogicalSessionExport, createSettingsDirectoryPicker, decideAccess, defaultOverlayPath, emptyCatalog, ensureWorkspace, expiredRouteRecordSchema, feishuDomain, foldAll, foldEvent, foldHealthSnapshot, foldSessionStats, inflightSnapshotSchema, inject, isAdminOpenId, isSameOriginRequest, isSupportedCommand, maskOpenIdTail, measureSessionContext, mergeOverlay, mountSettingsRoutes, name, normalizeCredentialValue, normalizeOverlay, normalizeRouteRecord, normalizeSessionHistory, parseOverlay, parseSlashCommand, pickEditable, processedRecordSchema, querySessionHistory, readOnlyFields, readProjectionViews, registerChannelCommands, registerModelCommand, registerSettingsRoutes, resolveDshHome, resolveWorkDir, routeRecordSchema, segmentCardContent, serializeLogicalSession, sessionHistoryEntrySchema, splitBodyForCard, stagingPathFor, stripTables, supportedCommandHelp, supportedCommandList, tableBlocks, topicTokenFor, usageFromBuckets, withDirectoryBrowse, workspacePathFor, workspaceTitleFor };
10216
10585
 
10217
10586
  //# sourceMappingURL=index.js.map