@modesto_ma/dsh-channels 0.1.0-beta.1 → 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/README.md +39 -29
- package/docs/access-control-design.md +4 -4
- package/docs/feishu-onboarding.md +11 -7
- package/lib/client.js +1698 -875
- package/lib/client.js.map +1 -1
- package/lib/index.js +447 -78
- package/lib/index.js.map +1 -1
- package/package.json +16 -14
- package/src/client/api.ts +20 -0
- package/src/client/catalog.ts +1 -1
- package/src/client/index.ts +1 -1
- package/src/client/panel/access-card.tsx +294 -0
- package/src/client/panel/access-tab.tsx +46 -0
- package/src/client/panel/advanced-tab.tsx +80 -0
- package/src/client/panel/cleanup-card.tsx +104 -0
- package/src/client/panel/connection-tab.tsx +214 -0
- package/src/client/panel/credentials-card.tsx +104 -0
- package/src/client/panel/index.tsx +862 -0
- package/src/client/panel/model-tab.tsx +23 -0
- package/src/client/panel/sessions-tab.tsx +21 -0
- package/src/client/panel/shared.tsx +488 -0
- package/src/client/panel/workspace-tab.tsx +40 -0
- package/src/client/styles.ts +24 -6
- package/src/config.ts +17 -4
- package/src/index.ts +24 -8
- package/src/service.ts +142 -26
- package/src/settings/contract.ts +207 -35
- package/src/settings/directory-picker.ts +121 -0
- package/src/settings/routes.ts +49 -2
- package/src/settings/surface.ts +159 -21
- package/src/client/panel.tsx +0 -1475
package/src/settings/surface.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
AccessMutationResponse,
|
|
24
24
|
AccessWire,
|
|
25
25
|
CatalogWire,
|
|
26
|
+
ChannelPhaseWire,
|
|
26
27
|
ChannelStatusWire,
|
|
27
28
|
CleanupPreviewWire,
|
|
28
29
|
CleanupResponse,
|
|
@@ -51,15 +52,30 @@ import {
|
|
|
51
52
|
} from './overlay.ts'
|
|
52
53
|
import type { OverlayLogger, OverlayPatch, OverlayStore } from './overlay.ts'
|
|
53
54
|
import type { SettingsCatalog } from './catalog.ts'
|
|
55
|
+
import type { SettingsDirectoryPicker } from './directory-picker.ts'
|
|
54
56
|
|
|
55
57
|
/** The slice of `FeishuService` the panel needs (structural, easy to fake). */
|
|
56
58
|
export interface SettingsTarget {
|
|
57
59
|
isRunning(): boolean
|
|
58
60
|
isRestarting(): boolean
|
|
61
|
+
/** True while an explicit connect action is in flight (panel button busy state). */
|
|
62
|
+
isConnecting(): boolean
|
|
59
63
|
health(): HealthSnapshot
|
|
60
64
|
startedAtMs(): number | undefined
|
|
61
65
|
uptimeMs(): number | undefined
|
|
62
66
|
startError(): string | undefined
|
|
67
|
+
/**
|
|
68
|
+
* Whether the Feishu application credentials resolve. A false value means the
|
|
69
|
+
* plugin is loaded but deliberately holds no connection: the panel guides the
|
|
70
|
+
* user through configuring credentials instead of reporting a failure.
|
|
71
|
+
*/
|
|
72
|
+
isConfigured(): boolean
|
|
73
|
+
/** Human-readable names of the missing configuration items (never values). */
|
|
74
|
+
missingConfig(): readonly string[]
|
|
75
|
+
/** Re-read credential presence (the credentials store is hot-reloaded on disk). */
|
|
76
|
+
refreshReadiness(): Promise<void>
|
|
77
|
+
/** Connect with the current config; throws when credentials are incomplete or the handshake fails. */
|
|
78
|
+
connect(): Promise<void>
|
|
63
79
|
configSnapshot(): FeishuConfig
|
|
64
80
|
baseConfigSnapshot(): FeishuConfig
|
|
65
81
|
runningConfigSnapshot(): FeishuConfig | undefined
|
|
@@ -164,7 +180,7 @@ const FIELD_META: Record<EditableKey, { label: string; help: string; min?: numbe
|
|
|
164
180
|
},
|
|
165
181
|
authRequestTtlDays: {
|
|
166
182
|
label: '授权申请有效期(天)',
|
|
167
|
-
help: '入群/用户授权申请卡的有效期,1–30
|
|
183
|
+
help: '入群/用户授权申请卡的有效期,1–30 天;过期后卡片需重新触发申请(也可在本页下方「待处理申请」中处理)。',
|
|
168
184
|
min: 1,
|
|
169
185
|
max: 30,
|
|
170
186
|
step: 1,
|
|
@@ -190,9 +206,9 @@ const GROUP_OF: Record<EditableKey, EditableFieldWire['group']> = {
|
|
|
190
206
|
threadFallback: 'behavior',
|
|
191
207
|
welcomeOnboarding: 'behavior',
|
|
192
208
|
allowPermissionSwitch: 'behavior',
|
|
193
|
-
adminOpenId: '
|
|
194
|
-
accessMode: '
|
|
195
|
-
authRequestTtlDays: '
|
|
209
|
+
adminOpenId: 'access',
|
|
210
|
+
accessMode: 'access',
|
|
211
|
+
authRequestTtlDays: 'access',
|
|
196
212
|
acceptAttachments: 'content',
|
|
197
213
|
provider: 'model',
|
|
198
214
|
model: 'model',
|
|
@@ -296,6 +312,23 @@ function withCurrentOption(
|
|
|
296
312
|
return inherit === undefined ? [extra] : [inherit, extra, ...rest]
|
|
297
313
|
}
|
|
298
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Mark the workspace-root field as browsable when the host can open its native
|
|
317
|
+
* directory chooser. Every other host (no `directoryPicker`, or the `browse`
|
|
318
|
+
* backend of remote/headless sessions) keeps the plain text input.
|
|
319
|
+
*
|
|
320
|
+
* @param fields - Host field descriptors after the selector catalog is applied.
|
|
321
|
+
* @param available - whether a native directory chooser can be opened.
|
|
322
|
+
* @returns the descriptors with `browse: 'directory'` on `baseDir` when usable.
|
|
323
|
+
*/
|
|
324
|
+
export function withDirectoryBrowse(
|
|
325
|
+
fields: readonly EditableFieldWire[],
|
|
326
|
+
available: boolean,
|
|
327
|
+
): EditableFieldWire[] {
|
|
328
|
+
if (!available) return [...fields]
|
|
329
|
+
return fields.map(field => (field.key === 'baseDir' ? { ...field, browse: 'directory' } : field))
|
|
330
|
+
}
|
|
331
|
+
|
|
299
332
|
function messageOf(error: unknown): string {
|
|
300
333
|
return error instanceof Error ? error.message : String(error)
|
|
301
334
|
}
|
|
@@ -377,12 +410,34 @@ export function readOnlyFields(config: FeishuConfig): ReadOnlyFieldWire[] {
|
|
|
377
410
|
]
|
|
378
411
|
}
|
|
379
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Derive the panel phase from readiness + runtime facts. The order matters:
|
|
415
|
+
* an incomplete configuration always wins (no connection is attempted), and a
|
|
416
|
+
* failed attempt is only reported while the channel is stopped.
|
|
417
|
+
*/
|
|
418
|
+
function phaseOf(target: SettingsTarget): ChannelPhaseWire {
|
|
419
|
+
// A connection attempt in flight is the honest label even though `start()`
|
|
420
|
+
// flips `started` before the handshake finishes. A live connection outranks
|
|
421
|
+
// missing credentials: removing a stored value does not drop the open socket,
|
|
422
|
+
// so the panel keeps the connection actions available and flags the missing
|
|
423
|
+
// credential separately (`configured: false`).
|
|
424
|
+
if (target.isRestarting()) return 'connecting'
|
|
425
|
+
if (target.isConnecting()) return target.isConfigured() ? 'connecting' : 'unconfigured'
|
|
426
|
+
if (target.isRunning()) return 'connected'
|
|
427
|
+
if (!target.isConfigured()) return 'unconfigured'
|
|
428
|
+
if (target.startError() !== undefined) return 'error'
|
|
429
|
+
return 'idle'
|
|
430
|
+
}
|
|
431
|
+
|
|
380
432
|
/** Assemble the status half of the panel payload. */
|
|
381
433
|
function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatusWire {
|
|
382
434
|
const health = target.health()
|
|
383
435
|
const connection = health.connection
|
|
384
436
|
return {
|
|
385
437
|
running: target.isRunning(),
|
|
438
|
+
configured: target.isConfigured(),
|
|
439
|
+
phase: phaseOf(target),
|
|
440
|
+
missingConfig: [...target.missingConfig()],
|
|
386
441
|
level: health.level,
|
|
387
442
|
startedAt: target.startedAtMs(),
|
|
388
443
|
uptimeMs: target.uptimeMs(),
|
|
@@ -402,7 +457,7 @@ function buildStatus(target: SettingsTarget, overlayPath: string): ChannelStatus
|
|
|
402
457
|
reasons: health.reasons,
|
|
403
458
|
version: DSH_CHANNELS_VERSION,
|
|
404
459
|
overlayPath,
|
|
405
|
-
busy: target.isRestarting(),
|
|
460
|
+
busy: target.isRestarting() || target.isConnecting(),
|
|
406
461
|
}
|
|
407
462
|
}
|
|
408
463
|
|
|
@@ -419,10 +474,35 @@ export class SettingsSurface {
|
|
|
419
474
|
private readonly store: OverlayStore,
|
|
420
475
|
private readonly logger?: OverlayLogger,
|
|
421
476
|
private readonly catalog?: SettingsCatalog,
|
|
477
|
+
private readonly directoryPicker?: SettingsDirectoryPicker,
|
|
422
478
|
) {}
|
|
423
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Re-read credential presence. The credentials store is hot-reloaded from
|
|
482
|
+
* disk, so a value edited outside the panel becomes visible within one poll
|
|
483
|
+
* instead of only after a restart/connect.
|
|
484
|
+
*/
|
|
485
|
+
private async syncReadiness(): Promise<void> {
|
|
486
|
+
try {
|
|
487
|
+
await this.target.refreshReadiness()
|
|
488
|
+
} catch (error) {
|
|
489
|
+
this.logger?.warn?.('dsh-channels: 凭据状态刷新失败:%s', messageOf(error))
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Reject panel writes while the connection lifecycle is in flight: saving an
|
|
495
|
+
* overlay or credentials then would race `applyOverlay()`/`start()` and can
|
|
496
|
+
* record a running-config snapshot that never took effect.
|
|
497
|
+
*/
|
|
498
|
+
private writeBusy(): { ok: false; error: string } | undefined {
|
|
499
|
+
if (!this.target.isRestarting() && !this.target.isConnecting()) return undefined
|
|
500
|
+
return { ok: false, error: '飞书连接正在连接或重启,请稍候再试' }
|
|
501
|
+
}
|
|
502
|
+
|
|
424
503
|
/** Wire status only (used by the client's 5 s poll). */
|
|
425
504
|
async status(): Promise<ChannelStatusWire> {
|
|
505
|
+
await this.syncReadiness()
|
|
426
506
|
return buildStatus(this.target, this.store.file)
|
|
427
507
|
}
|
|
428
508
|
|
|
@@ -443,6 +523,7 @@ export class SettingsSurface {
|
|
|
443
523
|
|
|
444
524
|
/** Status + editable config with provenance + read-only facts. */
|
|
445
525
|
async panel(): Promise<PanelWire> {
|
|
526
|
+
await this.syncReadiness()
|
|
446
527
|
const base = this.target.baseConfigSnapshot()
|
|
447
528
|
const effective = this.target.configSnapshot()
|
|
448
529
|
const overlay = await this.store.load()
|
|
@@ -452,12 +533,15 @@ export class SettingsSurface {
|
|
|
452
533
|
? RESTART_KEYS.filter(key => effective[key] !== running[key])
|
|
453
534
|
: []
|
|
454
535
|
const catalog = await this.catalogFor(effective.provider, effective.model)
|
|
455
|
-
const fields =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
536
|
+
const fields = withDirectoryBrowse(
|
|
537
|
+
applyCatalog(FIELD_DESCRIPTORS, catalog, {
|
|
538
|
+
provider: effective.provider,
|
|
539
|
+
model: effective.model,
|
|
540
|
+
reasoningEffort: effective.reasoningEffort,
|
|
541
|
+
agentPreset: effective.agentPreset,
|
|
542
|
+
}),
|
|
543
|
+
this.directoryPicker?.available === true,
|
|
544
|
+
)
|
|
461
545
|
|
|
462
546
|
let credentialStates: { appId: CredentialInfo; appSecret: CredentialInfo } = {
|
|
463
547
|
appId: { configured: false, writable: false },
|
|
@@ -507,6 +591,7 @@ export class SettingsSurface {
|
|
|
507
591
|
access = {
|
|
508
592
|
mode: effective.accessMode,
|
|
509
593
|
admin: { configured: false },
|
|
594
|
+
loaded: false,
|
|
510
595
|
groups: [],
|
|
511
596
|
users: [],
|
|
512
597
|
requests: [],
|
|
@@ -532,6 +617,8 @@ export class SettingsSurface {
|
|
|
532
617
|
|
|
533
618
|
/** Validate + persist a partial edit; live fields take effect immediately. */
|
|
534
619
|
async save(values: EditableValues): Promise<SaveConfigResponse> {
|
|
620
|
+
const busy = this.writeBusy()
|
|
621
|
+
if (busy !== undefined) return busy
|
|
535
622
|
const unknown = Object.keys(values).filter(key => !(EDITABLE_KEYS as readonly string[]).includes(key))
|
|
536
623
|
if (unknown.length > 0) {
|
|
537
624
|
return { ok: false, error: `未知配置项:${unknown.join(', ')}` }
|
|
@@ -611,6 +698,8 @@ export class SettingsSurface {
|
|
|
611
698
|
|
|
612
699
|
/** Drop every Web override and fall back to the profile config. */
|
|
613
700
|
async clear(): Promise<ClearConfigResponse> {
|
|
701
|
+
const busy = this.writeBusy()
|
|
702
|
+
if (busy !== undefined) return busy
|
|
614
703
|
try {
|
|
615
704
|
await this.store.clear()
|
|
616
705
|
} catch (error) {
|
|
@@ -622,6 +711,8 @@ export class SettingsSurface {
|
|
|
622
711
|
|
|
623
712
|
/** Write provided credential values through the host seam (never echoed back). */
|
|
624
713
|
async saveCredentials(values: SaveCredentialsRequest): Promise<SaveCredentialsResponse> {
|
|
714
|
+
const busy = this.writeBusy()
|
|
715
|
+
if (busy !== undefined) return busy
|
|
625
716
|
const writes: Array<{ field: CredentialField; value: string }> = []
|
|
626
717
|
const issues: FieldIssueWire[] = []
|
|
627
718
|
for (const field of ['appId', 'appSecret'] as const) {
|
|
@@ -647,6 +738,8 @@ export class SettingsSurface {
|
|
|
647
738
|
|
|
648
739
|
/** Remove the stored value behind one credential reference. */
|
|
649
740
|
async clearCredential(field: CredentialField): Promise<ClearCredentialResponse> {
|
|
741
|
+
const busy = this.writeBusy()
|
|
742
|
+
if (busy !== undefined) return busy
|
|
650
743
|
if (field !== 'appId' && field !== 'appSecret') {
|
|
651
744
|
return { ok: false, error: '未知的凭据字段' }
|
|
652
745
|
}
|
|
@@ -659,36 +752,67 @@ export class SettingsSurface {
|
|
|
659
752
|
}
|
|
660
753
|
|
|
661
754
|
/**
|
|
662
|
-
* Credential values are resolved on connect.
|
|
663
|
-
*
|
|
664
|
-
*
|
|
755
|
+
* Credential values are resolved on connect. Opening a connection is gated on
|
|
756
|
+
* readiness: saving one half of the pair stores the value but keeps the
|
|
757
|
+
* plugin idle until the other half exists. When the channel is stopped and
|
|
758
|
+
* the pair is complete, connect immediately (nothing to interrupt); when it
|
|
759
|
+
* is running, the user reconnects to pick the value up.
|
|
665
760
|
*/
|
|
666
761
|
private async finishCredentialChange(kind: 'save' | 'clear'): Promise<SaveCredentialsResponse> {
|
|
667
762
|
if (this.target.isRunning()) {
|
|
668
763
|
return { ok: true, panel: await this.panel(), reconnect: 'pending' }
|
|
669
764
|
}
|
|
670
|
-
if (kind === 'clear') {
|
|
671
|
-
return { ok: true, panel: await this.panel(), reconnect: 'not-needed' }
|
|
765
|
+
if (kind === 'clear' || !this.target.isConfigured()) {
|
|
766
|
+
return { ok: true, panel: await this.panel(), reconnect: kind === 'clear' ? 'not-needed' : 'unconfigured' }
|
|
672
767
|
}
|
|
673
768
|
try {
|
|
674
|
-
await this.target.
|
|
769
|
+
await this.target.connect()
|
|
675
770
|
return { ok: true, panel: await this.panel(), reconnect: 'auto' }
|
|
676
771
|
} catch (error) {
|
|
772
|
+
// The value is stored, but the channel did not come up: report the
|
|
773
|
+
// failure state instead of claiming a successful connection.
|
|
677
774
|
return {
|
|
678
775
|
ok: true,
|
|
679
776
|
panel: await this.panel(),
|
|
680
|
-
reconnect: '
|
|
777
|
+
reconnect: 'failed',
|
|
681
778
|
autoReconnectError: messageOf(error),
|
|
682
779
|
}
|
|
683
780
|
}
|
|
684
781
|
}
|
|
685
782
|
|
|
686
|
-
/**
|
|
783
|
+
/**
|
|
784
|
+
* The panel's single connect/reconnect action: reopen a running connection,
|
|
785
|
+
* or open the first one when the channel is stopped. Credentials must be
|
|
786
|
+
* complete — otherwise the panel points the user back at the credentials form
|
|
787
|
+
* instead of surfacing a startup error.
|
|
788
|
+
*/
|
|
687
789
|
async reconnect(): Promise<ReconnectResponse> {
|
|
790
|
+
// Credentials may have been edited on disk since the last poll; re-check
|
|
791
|
+
// presence before refusing the action.
|
|
792
|
+
await this.syncReadiness()
|
|
793
|
+
if (!this.target.isConfigured()) {
|
|
794
|
+
return {
|
|
795
|
+
ok: false,
|
|
796
|
+
error: '飞书应用凭据尚未配置完整,请先填写 App ID 与 App Secret 并保存',
|
|
797
|
+
unconfigured: true,
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const wasRunning = this.target.isRunning()
|
|
688
801
|
try {
|
|
689
|
-
await this.target.restart()
|
|
802
|
+
if (wasRunning) await this.target.restart()
|
|
803
|
+
else await this.target.connect()
|
|
690
804
|
} catch (error) {
|
|
691
|
-
return { ok: false, error:
|
|
805
|
+
return { ok: false, error: `${wasRunning ? '重连' : '连接'}失败:${messageOf(error)}` }
|
|
806
|
+
}
|
|
807
|
+
// `restart()` intentionally does not throw when the credentials vanished;
|
|
808
|
+
// verify the connection actually exists before reporting success.
|
|
809
|
+
if (!this.target.isRunning()) {
|
|
810
|
+
return {
|
|
811
|
+
ok: false,
|
|
812
|
+
unconfigured: !this.target.isConfigured(),
|
|
813
|
+
error: this.target.startError()
|
|
814
|
+
?? (this.target.isConfigured() ? '飞书连接未建立' : '飞书应用凭据尚未配置完整,请先填写并保存凭据'),
|
|
815
|
+
}
|
|
692
816
|
}
|
|
693
817
|
return { ok: true, panel: await this.panel() }
|
|
694
818
|
}
|
|
@@ -754,12 +878,26 @@ export class SettingsSurface {
|
|
|
754
878
|
}
|
|
755
879
|
}
|
|
756
880
|
|
|
881
|
+
/**
|
|
882
|
+
* Open the host's native directory chooser (the panel's「选择目录…」button).
|
|
883
|
+
*
|
|
884
|
+
* @param signal - caller lifetime; abort closes the OS dialog.
|
|
885
|
+
* @returns the chosen absolute path, or `null` when the operator cancels.
|
|
886
|
+
* @throws when the host provides no native directory chooser.
|
|
887
|
+
*/
|
|
888
|
+
async pickDirectory(signal: AbortSignal): Promise<string | null> {
|
|
889
|
+
if (this.directoryPicker === undefined) throw new Error(PANEL_COPY.directoryPickFailed)
|
|
890
|
+
return await this.directoryPicker.pick(signal)
|
|
891
|
+
}
|
|
892
|
+
|
|
757
893
|
/**
|
|
758
894
|
* Run the workspace-retention cleanup once. Irreversible deletions are the
|
|
759
895
|
* target's responsibility; the surface only reports the outcome and returns
|
|
760
896
|
* a refreshed panel (its cleanup preview then reflects the remaining workspaces).
|
|
761
897
|
*/
|
|
762
898
|
async runCleanup(): Promise<CleanupResponse> {
|
|
899
|
+
const busy = this.writeBusy()
|
|
900
|
+
if (busy !== undefined) return busy
|
|
763
901
|
let result: CleanupRunWire
|
|
764
902
|
try {
|
|
765
903
|
result = await this.target.runWorkspaceCleanup()
|