@mhfire/dsh-im-bridge 0.1.7 → 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.
@@ -0,0 +1,153 @@
1
+ /* Plugin card chrome: matches the Host Plugins section cards. */
2
+
3
+ .card {
4
+ list-style: none;
5
+ border: 1px solid var(--dsw-alias-border-l2);
6
+ border-radius: 12px;
7
+ background: var(--dsw-alias-bg-layer-3);
8
+ transition: border-color .16s, background .16s;
9
+ }
10
+
11
+ .card:hover {
12
+ border-color: var(--dsw-alias-label-dimmed);
13
+ }
14
+
15
+ .cardOpen {
16
+ background: var(--dsw-alias-bg-layer-2);
17
+ border-color: var(--dsw-alias-label-dimmed);
18
+ }
19
+
20
+ .header {
21
+ width: 100%;
22
+ appearance: none;
23
+ border: 0;
24
+ background: none;
25
+ font: inherit;
26
+ color: inherit;
27
+ text-align: left;
28
+ cursor: pointer;
29
+ display: flex;
30
+ align-items: center;
31
+ gap: 12px;
32
+ padding: 14px 16px;
33
+ border-radius: 12px;
34
+ }
35
+
36
+ .header:focus-visible {
37
+ outline: 2px solid var(--dsw-alias-brand-primary);
38
+ outline-offset: -2px;
39
+ }
40
+
41
+ .headText {
42
+ flex: 1;
43
+ min-width: 0;
44
+ display: flex;
45
+ flex-direction: column;
46
+ gap: 4px;
47
+ }
48
+
49
+ .name {
50
+ font-size: 15px;
51
+ font-weight: 600;
52
+ line-height: 1.4;
53
+ color: var(--dsw-alias-label-primary);
54
+ }
55
+
56
+ .description {
57
+ font-size: 13px;
58
+ line-height: 1.5;
59
+ color: var(--dsw-alias-label-tertiary);
60
+ }
61
+
62
+ .chevron {
63
+ flex: none;
64
+ color: var(--dsw-alias-label-tertiary);
65
+ transition: transform .16s;
66
+ }
67
+
68
+ .chevronOpen {
69
+ transform: rotate(180deg);
70
+ }
71
+
72
+ .body {
73
+ border-top: 1px solid var(--dsw-alias-border-l2);
74
+ margin: 0 16px;
75
+ padding-bottom: 8px;
76
+ }
77
+
78
+ .readOnly {
79
+ margin: 12px 0 0;
80
+ font-size: 12px;
81
+ line-height: 1.5;
82
+ color: var(--dsw-alias-label-tertiary);
83
+ }
84
+
85
+ .pending {
86
+ flex: none;
87
+ border-radius: 999px;
88
+ padding: 1px 8px;
89
+ font-size: 11px;
90
+ line-height: 17px;
91
+ font-weight: 500;
92
+ white-space: nowrap;
93
+ background: var(--dsw-alias-bg-module-platform);
94
+ color: var(--dsw-alias-label-secondary);
95
+ }
96
+
97
+ .footer {
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: flex-end;
101
+ gap: 8px;
102
+ padding: 12px 0 4px;
103
+ border-top: 1px solid var(--dsw-alias-border-l2);
104
+ }
105
+
106
+ .failed {
107
+ flex: 1;
108
+ min-width: 0;
109
+ margin: 0;
110
+ font-size: 12px;
111
+ line-height: 1.5;
112
+ color: var(--dsw-alias-label-error);
113
+ }
114
+
115
+ .discard,
116
+ .save {
117
+ appearance: none;
118
+ border: 1px solid transparent;
119
+ border-radius: 8px;
120
+ padding: 5px 14px;
121
+ font: inherit;
122
+ font-size: 13px;
123
+ line-height: 1.5;
124
+ cursor: pointer;
125
+ }
126
+
127
+ .discard {
128
+ border-color: var(--dsw-alias-border-l2);
129
+ background: none;
130
+ color: var(--dsw-alias-label-secondary);
131
+ }
132
+
133
+ .discard:hover:not(:disabled) {
134
+ color: var(--dsw-alias-label-primary);
135
+ border-color: var(--dsw-alias-label-dimmed);
136
+ }
137
+
138
+ .save {
139
+ background: var(--dsw-alias-label-primary);
140
+ color: var(--dsw-alias-bg-layer-3);
141
+ }
142
+
143
+ .discard:disabled,
144
+ .save:disabled {
145
+ opacity: 0.4;
146
+ cursor: default;
147
+ }
148
+
149
+ .discard:focus-visible,
150
+ .save:focus-visible {
151
+ outline: 2px solid var(--dsw-alias-brand-primary);
152
+ outline-offset: 1px;
153
+ }
@@ -0,0 +1,83 @@
1
+ /** Expandable plugin card chrome matching the Host Plugins section. */
2
+
3
+ import { useState, type ReactElement, type ReactNode } from 'react'
4
+ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
5
+ import type { ImBridgeLocaleKey } from './locales.ts'
6
+ import type { CardShell } from './card-form.ts'
7
+ import css from './PluginCard.module.css'
8
+
9
+ /** Card chrome shared by this plugin's settings card. */
10
+ export interface PluginCardProps {
11
+ /** Locale reader for this card's copy. */
12
+ t: (key: ImBridgeLocaleKey) => string
13
+ /** Locale key of the plugin's name. */
14
+ titleKey: ImBridgeLocaleKey
15
+ /** Locale key of the line describing what these settings govern. */
16
+ descriptionKey: ImBridgeLocaleKey
17
+ /** The card's form state. */
18
+ state: CardShell
19
+ /** Write every staged edit. */
20
+ onSave: () => void
21
+ /** Drop every staged edit. */
22
+ onDiscard: () => void
23
+ /** The plugin's controls. */
24
+ children: ReactNode
25
+ }
26
+
27
+ /**
28
+ * Render one plugin card.
29
+ * @param props - locale copy, form state, and controls.
30
+ * @returns the card, or nothing when the namespace is unavailable.
31
+ */
32
+ export function PluginCard(props: PluginCardProps): ReactElement | null {
33
+ const [open, setOpen] = useState(false)
34
+ const { state } = props
35
+ if (!state.available) return null
36
+ const title = props.t(props.titleKey)
37
+ const blocked = !state.dirty || state.invalid || state.saving
38
+ return (
39
+ <li className={open ? `${css.card} ${css.cardOpen}` : css.card}>
40
+ <button
41
+ type="button"
42
+ className={css.header}
43
+ aria-expanded={open}
44
+ aria-label={`${props.t(open ? 'collapse' : 'expand')}: ${title}`}
45
+ onClick={() => { setOpen(current => !current) }}
46
+ >
47
+ <span className={css.headText}>
48
+ <span className={css.name}>{title}</span>
49
+ <span className={css.description}>{props.t(props.descriptionKey)}</span>
50
+ </span>
51
+ {state.dirty ? <span className={css.pending}>{props.t('unsaved')}</span> : null}
52
+ <IconChevronDownOutline14 className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
53
+ </button>
54
+ {open
55
+ ? (
56
+ <div className={css.body}>
57
+ {!state.writable ? <p className={css.readOnly} role="status">{props.t('readOnly')}</p> : null}
58
+ {props.children}
59
+ <div className={css.footer}>
60
+ {state.failed ? <p className={css.failed} role="status">{props.t('saveFailed')}</p> : null}
61
+ <button
62
+ type="button"
63
+ className={css.discard}
64
+ disabled={!state.dirty || state.saving}
65
+ onClick={props.onDiscard}
66
+ >
67
+ {props.t('discard')}
68
+ </button>
69
+ <button
70
+ type="button"
71
+ className={css.save}
72
+ disabled={blocked}
73
+ onClick={props.onSave}
74
+ >
75
+ {props.t(state.saving ? 'saving' : 'save')}
76
+ </button>
77
+ </div>
78
+ </div>
79
+ )
80
+ : null}
81
+ </li>
82
+ )
83
+ }
@@ -0,0 +1,135 @@
1
+ /** WeCom settings card: owned chrome, staged fields, save/discard. */
2
+
3
+ import type { ReactElement } from 'react'
4
+ import type { ImBridgeLocaleKey } from './locales.ts'
5
+ import type { WecomCardFace, WecomCardState } from './card-controller.ts'
6
+ import { PluginCard } from './PluginCard.tsx'
7
+ import { SecretField, ValueField } from './fields.tsx'
8
+
9
+ /** Props the slot renderer binds for this card. */
10
+ export interface WecomCardProps {
11
+ /** Translate a dictionary key of the `im-bridge` namespace. */
12
+ t: (key: ImBridgeLocaleKey) => string
13
+ /** Card snapshot bound from inject.hooks.wecomCard. */
14
+ useWecomCard: <S>(select: (state: WecomCardState) => S) => S
15
+ /** Stage draft text for one field. */
16
+ edit: WecomCardFace['edit']
17
+ /** Stage a clear back to the composition layer. */
18
+ resetField: WecomCardFace['resetField']
19
+ /** Write every staged edit. */
20
+ save: WecomCardFace['save']
21
+ /** Drop every staged edit. */
22
+ discard: WecomCardFace['discard']
23
+ }
24
+
25
+ /**
26
+ * Render the WeCom settings card.
27
+ * @param props - locale copy, snapshot hook, and form actions.
28
+ * @returns the card, or nothing when the namespace is unavailable.
29
+ */
30
+ export function WecomCard(props: WecomCardProps): ReactElement | null {
31
+ const { t } = props
32
+ const state = props.useWecomCard(snapshot => snapshot)
33
+ const disabled = !state.writable
34
+ const field = {
35
+ overriddenLabel: t('overridden'),
36
+ resetLabel: t('reset'),
37
+ invalidLabel: t('invalidNumber'),
38
+ disabled,
39
+ }
40
+ return (
41
+ <PluginCard
42
+ t={t}
43
+ titleKey="title"
44
+ descriptionKey="description"
45
+ state={state}
46
+ onSave={props.save}
47
+ onDiscard={props.discard}
48
+ >
49
+ <SecretField
50
+ id="im-bridge-botId"
51
+ label={t('botId')}
52
+ hint={t('secretHint')}
53
+ disabled={disabled}
54
+ text={state.botId.text}
55
+ configured={state.botIdConfigured}
56
+ stateLabel={state.botIdConfigured ? t('secretConfigured') : t('secretUnset')}
57
+ onEdit={text => { props.edit('botId', text) }}
58
+ />
59
+ <SecretField
60
+ id="im-bridge-secret"
61
+ label={t('secret')}
62
+ hint={t('secretHint')}
63
+ disabled={disabled}
64
+ text={state.secret.text}
65
+ configured={state.secretConfigured}
66
+ stateLabel={state.secretConfigured ? t('secretConfigured') : t('secretUnset')}
67
+ onEdit={text => { props.edit('secret', text) }}
68
+ />
69
+ <ValueField
70
+ id="im-bridge-allowFrom"
71
+ label={t('allowFrom')}
72
+ hint={t('allowFromHint')}
73
+ {...field}
74
+ {...state.allowFrom}
75
+ onEdit={text => { props.edit('allowFrom', text) }}
76
+ onReset={() => { props.resetField('allowFrom') }}
77
+ />
78
+ <ValueField
79
+ id="im-bridge-agentTimeoutSec"
80
+ label={t('agentTimeoutSec')}
81
+ hint={t('agentTimeoutSecHint')}
82
+ numeric
83
+ {...field}
84
+ {...state.agentTimeoutSec}
85
+ onEdit={text => { props.edit('agentTimeoutSec', text) }}
86
+ onReset={() => { props.resetField('agentTimeoutSec') }}
87
+ />
88
+ <ValueField
89
+ id="im-bridge-startHint"
90
+ label={t('startHint')}
91
+ hint={t('startHintHint')}
92
+ {...field}
93
+ {...state.startHint}
94
+ onEdit={text => { props.edit('startHint', text) }}
95
+ onReset={() => { props.resetField('startHint') }}
96
+ />
97
+ <ValueField
98
+ id="im-bridge-deniedMessage"
99
+ label={t('deniedMessage')}
100
+ hint={t('deniedMessageHint')}
101
+ {...field}
102
+ {...state.deniedMessage}
103
+ onEdit={text => { props.edit('deniedMessage', text) }}
104
+ onReset={() => { props.resetField('deniedMessage') }}
105
+ />
106
+ <ValueField
107
+ id="im-bridge-welcomeMessage"
108
+ label={t('welcomeMessage')}
109
+ hint={t('welcomeMessageHint')}
110
+ {...field}
111
+ {...state.welcomeMessage}
112
+ onEdit={text => { props.edit('welcomeMessage', text) }}
113
+ onReset={() => { props.resetField('welcomeMessage') }}
114
+ />
115
+ <ValueField
116
+ id="im-bridge-provider"
117
+ label={t('provider')}
118
+ hint={t('providerHint')}
119
+ {...field}
120
+ {...state.provider}
121
+ onEdit={text => { props.edit('provider', text) }}
122
+ onReset={() => { props.resetField('provider') }}
123
+ />
124
+ <ValueField
125
+ id="im-bridge-model"
126
+ label={t('model')}
127
+ hint={t('modelHint')}
128
+ {...field}
129
+ {...state.model}
130
+ onEdit={text => { props.edit('model', text) }}
131
+ onReset={() => { props.resetField('model') }}
132
+ />
133
+ </PluginCard>
134
+ )
135
+ }
@@ -0,0 +1,146 @@
1
+ /** Staged form over the `im-bridge` settings namespace. */
2
+
3
+ import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
4
+ import {
5
+ CardForm,
6
+ csvField,
7
+ numberField,
8
+ textField,
9
+ type CardActions,
10
+ type CardFieldState,
11
+ type CardShell,
12
+ } from './card-form.ts'
13
+
14
+ /** One redacted secret slot from `settings.describe`. */
15
+ interface SecretSlotView {
16
+ path: readonly string[]
17
+ set: boolean
18
+ }
19
+
20
+ /**
21
+ * Cross-namespace describe face. Typed locally so this out-of-tree package
22
+ * does not value- or type-import `@deepseek-ai/dsh-client-ui-settings`.
23
+ */
24
+ interface SettingsDescribeFace {
25
+ getSnapshot(): {
26
+ view?: {
27
+ namespaces: ReadonlyArray<{
28
+ ns: string
29
+ secrets: readonly SecretSlotView[]
30
+ }>
31
+ }
32
+ }
33
+ }
34
+
35
+ /** Settings namespace paired with this card. */
36
+ const NS = 'im-bridge'
37
+
38
+ /** User-editable slice of the im-bridge settings namespace. */
39
+ export interface WecomCardSettings {
40
+ allowFrom?: string[]
41
+ agentTimeoutSec?: number
42
+ startHint?: string
43
+ deniedMessage?: string
44
+ welcomeMessage?: string
45
+ provider?: string
46
+ model?: string
47
+ }
48
+
49
+ /** Snapshot the WeCom card renders. */
50
+ export interface WecomCardState extends CardShell {
51
+ botId: CardFieldState
52
+ secret: CardFieldState
53
+ botIdConfigured: boolean
54
+ secretConfigured: boolean
55
+ allowFrom: CardFieldState
56
+ agentTimeoutSec: CardFieldState
57
+ startHint: CardFieldState
58
+ deniedMessage: CardFieldState
59
+ welcomeMessage: CardFieldState
60
+ provider: CardFieldState
61
+ model: CardFieldState
62
+ }
63
+
64
+ /** Face the card's slot registration injects. */
65
+ export interface WecomCardFace extends CardActions {
66
+ hooks: {
67
+ /** Card snapshot bound by the renderer as useWecomCard. */
68
+ wecomCard: SnapshotStore<WecomCardState>
69
+ }
70
+ }
71
+
72
+ /** Bridges the `im-bridge` scope onto the staged card form. */
73
+ export class WecomCardController {
74
+ private readonly form: CardForm<WecomCardSettings>
75
+ private readonly store: SnapshotStore<WecomCardState>
76
+
77
+ /**
78
+ * @param scope - bound settings scope for the `im-bridge` namespace.
79
+ * @param describe - Host describe face; secret literals never ride it.
80
+ */
81
+ constructor(
82
+ private readonly scope: SettingsScope<WecomCardSettings>,
83
+ private readonly describe: SettingsDescribeFace,
84
+ ) {
85
+ this.form = new CardForm(
86
+ scope,
87
+ [
88
+ csvField('allowFrom'),
89
+ numberField('agentTimeoutSec'),
90
+ textField('startHint'),
91
+ textField('deniedMessage'),
92
+ textField('welcomeMessage'),
93
+ textField('provider'),
94
+ textField('model'),
95
+ ],
96
+ [
97
+ { field: 'botId', write: text => this.writeSecret('botId', text) },
98
+ { field: 'secret', write: text => this.writeSecret('secret', text) },
99
+ ],
100
+ )
101
+ this.store = this.form.bind(() => this.projection())
102
+ }
103
+
104
+ private projection(): WecomCardState {
105
+ return {
106
+ ...this.form.shell(),
107
+ botId: this.form.field('botId'),
108
+ secret: this.form.field('secret'),
109
+ botIdConfigured: this.secretConfigured('botId'),
110
+ secretConfigured: this.secretConfigured('secret'),
111
+ allowFrom: this.form.field('allowFrom'),
112
+ agentTimeoutSec: this.form.field('agentTimeoutSec'),
113
+ startHint: this.form.field('startHint'),
114
+ deniedMessage: this.form.field('deniedMessage'),
115
+ welcomeMessage: this.form.field('welcomeMessage'),
116
+ provider: this.form.field('provider'),
117
+ model: this.form.field('model'),
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Whether the Host reports a configured value for one secret slot.
123
+ * @param field - `botId` or `secret`.
124
+ * @returns true when describe lists that slot as set.
125
+ */
126
+ private secretConfigured(field: string): boolean {
127
+ const row = this.describe.getSnapshot().view?.namespaces.find(candidate => candidate.ns === NS)
128
+ return row?.secrets.some(slot => slot.path[0] === field && slot.set) === true
129
+ }
130
+
131
+ /**
132
+ * Write one credential into the user layer, then read configured state back.
133
+ * @param field - `botId` or `secret`.
134
+ * @param text - the staged literal.
135
+ * @returns whether describe reports the slot set afterwards.
136
+ */
137
+ private async writeSecret(field: string, text: string): Promise<boolean> {
138
+ await this.scope.set(field, text)
139
+ return this.secretConfigured(field)
140
+ }
141
+
142
+ /** Face the slot registration injects. */
143
+ inject(): WecomCardFace {
144
+ return { hooks: { wecomCard: this.store }, ...this.form.actions() }
145
+ }
146
+ }