@mhfire/dsh-im-bridge 0.1.3 → 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.en.md +114 -82
- package/README.md +114 -82
- package/cordis.patch.yml +83 -47
- package/lib/client.js +789 -153
- package/lib/client.js.map +1 -0
- package/lib/index.js +570 -0
- package/package.json +76 -43
- package/persona.default.en.md +22 -0
- package/persona.default.md +22 -0
- package/src/client/PluginCard.module.css +153 -0
- package/src/client/PluginCard.tsx +83 -0
- package/src/client/WecomCard.tsx +135 -0
- package/src/client/card-controller.ts +146 -0
- package/src/client/card-form.ts +293 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/fields.module.css +124 -0
- package/src/client/fields.tsx +112 -0
- package/src/client/index.ts +37 -0
- package/src/client/locales.ts +112 -0
- package/src/index.ts +542 -0
- package/src/wecom.ts +234 -0
- package/tsconfig.json +14 -0
- package/tsdown.client.ts +109 -0
- package/tsdown.host.ts +36 -0
- package/src/index.js +0 -272
- package/src/wecom.js +0 -111
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged settings form owned by this plugin.
|
|
3
|
+
* Mirrors the Host Plugins section model without importing its chrome.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createSnapshotStore,
|
|
8
|
+
type SettingsScope,
|
|
9
|
+
type SettingsScopeSnapshot,
|
|
10
|
+
type SnapshotStore,
|
|
11
|
+
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
+
|
|
13
|
+
/** Write one staged field performs on save. */
|
|
14
|
+
export type FieldWrite =
|
|
15
|
+
| { kind: 'set'; value: unknown }
|
|
16
|
+
| { kind: 'clear' }
|
|
17
|
+
|
|
18
|
+
/** Convert one section field between stored value and draft text. */
|
|
19
|
+
export interface CardFieldSpec {
|
|
20
|
+
/** Field name inside the namespace section. */
|
|
21
|
+
field: string
|
|
22
|
+
/** Render a stored value as draft text. */
|
|
23
|
+
format: (value: unknown) => string
|
|
24
|
+
/** The write this draft stages, or undefined when the text is invalid. */
|
|
25
|
+
parse: (text: string) => FieldWrite | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A control whose literal never rides a response. The draft starts blank;
|
|
30
|
+
* a blank draft writes nothing so a save cannot clear a stored secret.
|
|
31
|
+
*/
|
|
32
|
+
export interface CardSecretSpec {
|
|
33
|
+
/** Field name addressing this control inside the card's form. */
|
|
34
|
+
field: string
|
|
35
|
+
/** Write the staged text; resolves to whether the Host accepted it. */
|
|
36
|
+
write: (text: string) => Promise<boolean>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One field as a control renders it. */
|
|
40
|
+
export interface CardFieldState {
|
|
41
|
+
/** Draft text the control renders. */
|
|
42
|
+
text: string
|
|
43
|
+
/** Whether saving would leave a user-layer entry. */
|
|
44
|
+
overridden: boolean
|
|
45
|
+
/** Whether the draft is not a value this field accepts. */
|
|
46
|
+
invalid: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Card-level form state. */
|
|
50
|
+
export interface CardShell {
|
|
51
|
+
/** False while the namespace is not served; the card renders nothing. */
|
|
52
|
+
available: boolean
|
|
53
|
+
/** Whether the Host document accepts writes. */
|
|
54
|
+
writable: boolean
|
|
55
|
+
/** Whether the form holds edits a save would write. */
|
|
56
|
+
dirty: boolean
|
|
57
|
+
/** Whether any staged draft is invalid. */
|
|
58
|
+
invalid: boolean
|
|
59
|
+
/** Whether a save is crossing the wire. */
|
|
60
|
+
saving: boolean
|
|
61
|
+
/** Whether the last save did not land. */
|
|
62
|
+
failed: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Write actions a card's slot entry injects. */
|
|
66
|
+
export interface CardActions {
|
|
67
|
+
/** Stage draft text for one field. */
|
|
68
|
+
edit: (field: string, text: string) => void
|
|
69
|
+
/** Stage a clear so the field re-inherits the composition layer. */
|
|
70
|
+
resetField: (field: string) => void
|
|
71
|
+
/** Write every staged edit, then re-seed from what the Host accepted. */
|
|
72
|
+
save: () => void
|
|
73
|
+
/** Drop every staged edit. */
|
|
74
|
+
discard: () => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface StagedEdit {
|
|
78
|
+
text: string
|
|
79
|
+
clear: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface PlannedWrite {
|
|
83
|
+
field: string
|
|
84
|
+
run: (() => Promise<boolean>) | undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whole-number field. Empty draft clears. */
|
|
88
|
+
export function numberField(field: string): CardFieldSpec {
|
|
89
|
+
return {
|
|
90
|
+
field,
|
|
91
|
+
format: value => typeof value === 'number' ? String(value) : '',
|
|
92
|
+
parse: (text) => {
|
|
93
|
+
const trimmed = text.trim()
|
|
94
|
+
if (trimmed === '') return { kind: 'clear' }
|
|
95
|
+
const parsed = Number(trimmed)
|
|
96
|
+
return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Free-text field. Empty draft clears. */
|
|
102
|
+
export function textField(field: string): CardFieldSpec {
|
|
103
|
+
return {
|
|
104
|
+
field,
|
|
105
|
+
format: value => typeof value === 'string' ? value : '',
|
|
106
|
+
parse: (text) => {
|
|
107
|
+
const trimmed = text.trim()
|
|
108
|
+
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Comma-separated string list. Empty draft stores []. */
|
|
114
|
+
export function csvField(field: string): CardFieldSpec {
|
|
115
|
+
return {
|
|
116
|
+
field,
|
|
117
|
+
format: value => Array.isArray(value) ? value.join(',') : '',
|
|
118
|
+
parse: (text) => {
|
|
119
|
+
const items = text.split(',').map(item => item.trim()).filter(Boolean)
|
|
120
|
+
return { kind: 'set', value: items }
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Stages edits over one settings namespace and writes them on save. */
|
|
126
|
+
export class CardForm<T> {
|
|
127
|
+
private readonly specs: Map<string, CardFieldSpec>
|
|
128
|
+
private readonly secretSpecs: Map<string, CardSecretSpec>
|
|
129
|
+
private readonly staged = new Map<string, StagedEdit>()
|
|
130
|
+
private readonly listeners = new Set<() => void>()
|
|
131
|
+
private saving = false
|
|
132
|
+
private failed = false
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param scope - bound settings scope for this card's namespace.
|
|
136
|
+
* @param specs - section fields this card edits.
|
|
137
|
+
* @param secrets - write-only controls; a blank draft is a no-op.
|
|
138
|
+
*/
|
|
139
|
+
constructor(
|
|
140
|
+
private readonly scope: SettingsScope<T>,
|
|
141
|
+
specs: CardFieldSpec[],
|
|
142
|
+
secrets: CardSecretSpec[] = [],
|
|
143
|
+
) {
|
|
144
|
+
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
145
|
+
this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec]))
|
|
146
|
+
scope.subscribe(() => { this.publish() })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Publish a projection rebuilt whenever the scope or a draft changes. */
|
|
150
|
+
bind<S>(project: () => S): SnapshotStore<S> {
|
|
151
|
+
const store = createSnapshotStore(project())
|
|
152
|
+
this.listeners.add(() => { store.set(project()) })
|
|
153
|
+
return store
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Card-level state: what the Host serves and what a save would do. */
|
|
157
|
+
shell(): CardShell {
|
|
158
|
+
const snapshot = this.scope.getSnapshot()
|
|
159
|
+
const plan = this.plan()
|
|
160
|
+
return {
|
|
161
|
+
available: snapshot.status === 'ready',
|
|
162
|
+
writable: snapshot.writable,
|
|
163
|
+
dirty: plan.length > 0,
|
|
164
|
+
invalid: plan.some(item => item.run === undefined),
|
|
165
|
+
saving: this.saving,
|
|
166
|
+
failed: this.failed,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One control's staged text, override badge, and validity. */
|
|
171
|
+
field(field: string): CardFieldState {
|
|
172
|
+
const staged = this.staged.get(field)
|
|
173
|
+
if (this.secretSpecs.has(field)) {
|
|
174
|
+
return { text: staged?.text ?? '', overridden: false, invalid: false }
|
|
175
|
+
}
|
|
176
|
+
const spec = this.spec(field)
|
|
177
|
+
if (staged === undefined) {
|
|
178
|
+
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
|
179
|
+
}
|
|
180
|
+
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
|
181
|
+
return {
|
|
182
|
+
text: staged.text,
|
|
183
|
+
overridden: write?.kind === 'set',
|
|
184
|
+
invalid: write === undefined,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Edit, reset, save, and discard actions bound to this form. */
|
|
189
|
+
actions(): CardActions {
|
|
190
|
+
return {
|
|
191
|
+
edit: (field, text) => { this.stage(field, { text, clear: false }) },
|
|
192
|
+
resetField: (field) => {
|
|
193
|
+
this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true })
|
|
194
|
+
},
|
|
195
|
+
save: () => { void this.save() },
|
|
196
|
+
discard: () => {
|
|
197
|
+
if (this.staged.size === 0 && !this.failed) return
|
|
198
|
+
this.staged.clear()
|
|
199
|
+
this.failed = false
|
|
200
|
+
this.publish()
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Write every staged edit, then re-seed from what the Host accepted. */
|
|
206
|
+
async save(): Promise<void> {
|
|
207
|
+
const plan = this.plan()
|
|
208
|
+
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
|
|
209
|
+
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
|
|
210
|
+
this.saving = true
|
|
211
|
+
this.failed = false
|
|
212
|
+
this.publish()
|
|
213
|
+
let landed = true
|
|
214
|
+
for (const write of writes) {
|
|
215
|
+
landed = await write() && landed
|
|
216
|
+
}
|
|
217
|
+
if (landed) this.staged.clear()
|
|
218
|
+
this.saving = false
|
|
219
|
+
this.failed = !landed
|
|
220
|
+
this.publish()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private plan(): PlannedWrite[] {
|
|
224
|
+
const plan: PlannedWrite[] = []
|
|
225
|
+
for (const [field, staged] of this.staged) {
|
|
226
|
+
const secret = this.secretSpecs.get(field)
|
|
227
|
+
if (secret !== undefined) {
|
|
228
|
+
const value = staged.text.trim()
|
|
229
|
+
if (value !== '') plan.push({ field, run: () => secret.write(value) })
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
const spec = this.spec(field)
|
|
233
|
+
if (staged.clear) {
|
|
234
|
+
if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
238
|
+
const write = spec.parse(staged.text)
|
|
239
|
+
if (write === undefined) plan.push({ field, run: undefined })
|
|
240
|
+
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
|
241
|
+
else plan.push({ field, run: () => this.store(field, write.value) })
|
|
242
|
+
}
|
|
243
|
+
return plan
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async clear(field: string): Promise<boolean> {
|
|
247
|
+
await this.scope.unset(field)
|
|
248
|
+
return !this.stored(field)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async store(field: string, value: unknown): Promise<boolean> {
|
|
252
|
+
await this.scope.set(field, value)
|
|
253
|
+
return this.userLayer()?.[field] === value
|
|
254
|
+
|| (Array.isArray(value) && JSON.stringify(this.userLayer()?.[field]) === JSON.stringify(value))
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private stage(field: string, edit: StagedEdit): void {
|
|
258
|
+
this.staged.set(field, edit)
|
|
259
|
+
this.failed = false
|
|
260
|
+
this.publish()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private spec(field: string): CardFieldSpec {
|
|
264
|
+
const spec = this.specs.get(field)
|
|
265
|
+
if (spec === undefined) throw new Error(`im-bridge card has no field ${field}`)
|
|
266
|
+
return spec
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private snapshotOf(): SettingsScopeSnapshot<T> {
|
|
270
|
+
return this.scope.getSnapshot()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private sectionValue(field: string): unknown {
|
|
274
|
+
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private baseValue(field: string): unknown {
|
|
278
|
+
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private userLayer(): Record<string, unknown> | undefined {
|
|
282
|
+
return this.snapshotOf().user as Record<string, unknown> | undefined
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private stored(field: string): boolean {
|
|
286
|
+
const user = this.userLayer()
|
|
287
|
+
return user !== undefined && Object.hasOwn(user, field)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private publish(): void {
|
|
291
|
+
for (const listener of this.listeners) listener()
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* Plugin configuration fields: label, control, override badge, and hint. */
|
|
2
|
+
|
|
3
|
+
.field {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
gap: 6px;
|
|
7
|
+
padding: 12px 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.field + .field {
|
|
11
|
+
border-top: 1px solid var(--dsw-alias-border-l2);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.head {
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: 8px;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.label {
|
|
21
|
+
flex: 1;
|
|
22
|
+
min-width: 0;
|
|
23
|
+
font-size: 13px;
|
|
24
|
+
font-weight: 500;
|
|
25
|
+
line-height: 1.5;
|
|
26
|
+
color: var(--dsw-alias-label-primary);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.badges {
|
|
30
|
+
display: inline-flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: 8px;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.badge {
|
|
36
|
+
border-radius: 999px;
|
|
37
|
+
padding: 1px 8px;
|
|
38
|
+
font-size: 11px;
|
|
39
|
+
line-height: 17px;
|
|
40
|
+
white-space: nowrap;
|
|
41
|
+
font-weight: 500;
|
|
42
|
+
background: var(--dsw-alias-bg-module-platform);
|
|
43
|
+
color: var(--dsw-alias-label-secondary);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.badgeMuted {
|
|
47
|
+
border-radius: 999px;
|
|
48
|
+
padding: 1px 8px;
|
|
49
|
+
font-size: 11px;
|
|
50
|
+
line-height: 17px;
|
|
51
|
+
white-space: nowrap;
|
|
52
|
+
color: var(--dsw-alias-label-tertiary);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.reset {
|
|
56
|
+
border: none;
|
|
57
|
+
background: none;
|
|
58
|
+
padding: 0;
|
|
59
|
+
font: inherit;
|
|
60
|
+
font-size: 12px;
|
|
61
|
+
line-height: 1.5;
|
|
62
|
+
color: var(--dsw-alias-label-secondary);
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.reset:hover:not(:disabled) {
|
|
67
|
+
color: var(--dsw-alias-label-primary);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.reset:disabled {
|
|
71
|
+
cursor: default;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.input {
|
|
75
|
+
height: 34px;
|
|
76
|
+
padding: 0 12px;
|
|
77
|
+
border: 1px solid var(--dsw-alias-border-l2);
|
|
78
|
+
border-radius: 8px;
|
|
79
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
80
|
+
font: inherit;
|
|
81
|
+
font-size: 13px;
|
|
82
|
+
line-height: 1.5;
|
|
83
|
+
color: var(--dsw-alias-label-primary);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.input:focus-visible {
|
|
87
|
+
outline: none;
|
|
88
|
+
border-color: var(--dsw-alias-brand-primary);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.input:disabled {
|
|
92
|
+
color: var(--dsw-alias-label-tertiary);
|
|
93
|
+
cursor: default;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.inputInvalid {
|
|
97
|
+
height: 34px;
|
|
98
|
+
padding: 0 12px;
|
|
99
|
+
border: 1px solid var(--dsw-alias-label-error);
|
|
100
|
+
border-radius: 8px;
|
|
101
|
+
background: var(--dsw-alias-bg-layer-3);
|
|
102
|
+
font: inherit;
|
|
103
|
+
font-size: 13px;
|
|
104
|
+
line-height: 1.5;
|
|
105
|
+
color: var(--dsw-alias-label-primary);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.inputInvalid:focus-visible {
|
|
109
|
+
outline: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.invalid {
|
|
113
|
+
margin: 0;
|
|
114
|
+
font-size: 12px;
|
|
115
|
+
line-height: 1.5;
|
|
116
|
+
color: var(--dsw-alias-label-error);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.hint {
|
|
120
|
+
margin: 0;
|
|
121
|
+
font-size: 12px;
|
|
122
|
+
line-height: 1.5;
|
|
123
|
+
color: var(--dsw-alias-label-tertiary);
|
|
124
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Staged value and write-only secret controls. */
|
|
2
|
+
|
|
3
|
+
import type { ReactElement } from 'react'
|
|
4
|
+
import css from './fields.module.css'
|
|
5
|
+
|
|
6
|
+
/** Shared props for a labelled field control. */
|
|
7
|
+
export interface FieldProps {
|
|
8
|
+
/** Stable id associating the label with its control. */
|
|
9
|
+
id: string
|
|
10
|
+
/** Visible label. */
|
|
11
|
+
label: string
|
|
12
|
+
/** One-line explanation under the control. */
|
|
13
|
+
hint: string
|
|
14
|
+
/** Draft text the control renders. */
|
|
15
|
+
text: string
|
|
16
|
+
/** Whether saving would leave a user-layer entry. */
|
|
17
|
+
overridden: boolean
|
|
18
|
+
/** Whether the draft is not a value this field accepts. */
|
|
19
|
+
invalid: boolean
|
|
20
|
+
/** Copy for the overridden badge. */
|
|
21
|
+
overriddenLabel: string
|
|
22
|
+
/** Copy for the reset control. */
|
|
23
|
+
resetLabel: string
|
|
24
|
+
/** Copy shown in place of the hint while the draft is invalid. */
|
|
25
|
+
invalidLabel: string
|
|
26
|
+
/** Disables every control. */
|
|
27
|
+
disabled: boolean
|
|
28
|
+
/** Stage draft text. */
|
|
29
|
+
onEdit: (text: string) => void
|
|
30
|
+
/** Stage a clear so the field re-inherits the composition layer. */
|
|
31
|
+
onReset: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A staged value field.
|
|
36
|
+
* @param props - the field's copy, staged text, and edit actions.
|
|
37
|
+
* @returns the labelled control.
|
|
38
|
+
*/
|
|
39
|
+
export function ValueField(props: FieldProps & {
|
|
40
|
+
/** Hints a numeric keypad without narrowing accepted drafts. */
|
|
41
|
+
numeric?: boolean
|
|
42
|
+
}): ReactElement {
|
|
43
|
+
return (
|
|
44
|
+
<div className={css.field}>
|
|
45
|
+
<div className={css.head}>
|
|
46
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
47
|
+
{props.overridden
|
|
48
|
+
? (
|
|
49
|
+
<span className={css.badges}>
|
|
50
|
+
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
className={css.reset}
|
|
54
|
+
disabled={props.disabled}
|
|
55
|
+
onClick={props.onReset}
|
|
56
|
+
>
|
|
57
|
+
{props.resetLabel}
|
|
58
|
+
</button>
|
|
59
|
+
</span>
|
|
60
|
+
)
|
|
61
|
+
: null}
|
|
62
|
+
</div>
|
|
63
|
+
<input
|
|
64
|
+
id={props.id}
|
|
65
|
+
className={props.invalid ? css.inputInvalid : css.input}
|
|
66
|
+
type="text"
|
|
67
|
+
{...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
|
|
68
|
+
{...props.invalid ? { 'aria-invalid': true } : {}}
|
|
69
|
+
value={props.text}
|
|
70
|
+
disabled={props.disabled}
|
|
71
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
72
|
+
/>
|
|
73
|
+
<p className={props.invalid ? css.invalid : css.hint}>
|
|
74
|
+
{props.invalid ? props.invalidLabel : props.hint}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write-only credential control. The literal never rides a response, so the
|
|
82
|
+
* control starts blank and reports only whether one is configured.
|
|
83
|
+
* @param props - the field's copy, staged text, and configured state.
|
|
84
|
+
* @returns the labelled control.
|
|
85
|
+
*/
|
|
86
|
+
export function SecretField(props: Pick<FieldProps, 'id' | 'label' | 'hint' | 'text' | 'disabled' | 'onEdit'> & {
|
|
87
|
+
/** Whether the Host reports a configured value for this secret slot. */
|
|
88
|
+
configured: boolean
|
|
89
|
+
/** Copy describing the configured state. */
|
|
90
|
+
stateLabel: string
|
|
91
|
+
}): ReactElement {
|
|
92
|
+
return (
|
|
93
|
+
<div className={css.field}>
|
|
94
|
+
<div className={css.head}>
|
|
95
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
96
|
+
<span className={css.badges}>
|
|
97
|
+
<span className={props.configured ? css.badge : css.badgeMuted}>{props.stateLabel}</span>
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
<input
|
|
101
|
+
id={props.id}
|
|
102
|
+
className={css.input}
|
|
103
|
+
type="password"
|
|
104
|
+
autoComplete="off"
|
|
105
|
+
value={props.text}
|
|
106
|
+
disabled={props.disabled}
|
|
107
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
108
|
+
/>
|
|
109
|
+
<p className={css.hint}>{props.hint}</p>
|
|
110
|
+
</div>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half: Settings card keyed by the `im-bridge` namespace.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
6
|
+
// Type-only: ctx.settingsScope.describe and the keyed slot declaration.
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
|
|
9
|
+
import { WecomCard } from './WecomCard.tsx'
|
|
10
|
+
import { WecomCardController } from './card-controller.ts'
|
|
11
|
+
import { en, zh } from './locales.ts'
|
|
12
|
+
|
|
13
|
+
/** Settings namespace shared with the Host half. */
|
|
14
|
+
const NS = 'im-bridge'
|
|
15
|
+
|
|
16
|
+
/** Required browser services. */
|
|
17
|
+
export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope']
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register locale copy and the Plugins-tab card.
|
|
21
|
+
* @param ctx - browser plugin context.
|
|
22
|
+
*/
|
|
23
|
+
export function apply(ctx: ClientContext): void {
|
|
24
|
+
const card = new WecomCardController(
|
|
25
|
+
ctx.settingsScope.bind({ namespace: NS }),
|
|
26
|
+
ctx.settingsScope.describe(),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'im-bridge: locale dicts')
|
|
30
|
+
|
|
31
|
+
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
|
|
32
|
+
name: 'settings.plugin.item',
|
|
33
|
+
key: NS,
|
|
34
|
+
locale: NS,
|
|
35
|
+
inject: () => card.inject(),
|
|
36
|
+
}, WecomCard))
|
|
37
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Dictionary keys owned by the im-bridge settings card. */
|
|
2
|
+
export type ImBridgeLocaleKey =
|
|
3
|
+
| 'title'
|
|
4
|
+
| 'description'
|
|
5
|
+
| 'unsaved'
|
|
6
|
+
| 'readOnly'
|
|
7
|
+
| 'saveFailed'
|
|
8
|
+
| 'save'
|
|
9
|
+
| 'saving'
|
|
10
|
+
| 'discard'
|
|
11
|
+
| 'expand'
|
|
12
|
+
| 'collapse'
|
|
13
|
+
| 'overridden'
|
|
14
|
+
| 'reset'
|
|
15
|
+
| 'invalidNumber'
|
|
16
|
+
| 'botId'
|
|
17
|
+
| 'secret'
|
|
18
|
+
| 'secretHint'
|
|
19
|
+
| 'secretConfigured'
|
|
20
|
+
| 'secretUnset'
|
|
21
|
+
| 'allowFrom'
|
|
22
|
+
| 'allowFromHint'
|
|
23
|
+
| 'agentTimeoutSec'
|
|
24
|
+
| 'agentTimeoutSecHint'
|
|
25
|
+
| 'startHint'
|
|
26
|
+
| 'startHintHint'
|
|
27
|
+
| 'deniedMessage'
|
|
28
|
+
| 'deniedMessageHint'
|
|
29
|
+
| 'welcomeMessage'
|
|
30
|
+
| 'welcomeMessageHint'
|
|
31
|
+
| 'provider'
|
|
32
|
+
| 'providerHint'
|
|
33
|
+
| 'model'
|
|
34
|
+
| 'modelHint'
|
|
35
|
+
|
|
36
|
+
/** English copy for the im-bridge card. */
|
|
37
|
+
export const en: Record<ImBridgeLocaleKey, string> = {
|
|
38
|
+
title: 'WeCom Bridge',
|
|
39
|
+
description: 'Credentials, allow-list, timeouts, and WeCom-only model overrides.',
|
|
40
|
+
unsaved: 'Unsaved',
|
|
41
|
+
readOnly: 'This document is read-only.',
|
|
42
|
+
saveFailed: 'Save did not land. Correct the fields and try again.',
|
|
43
|
+
save: 'Save',
|
|
44
|
+
saving: 'Saving…',
|
|
45
|
+
discard: 'Discard',
|
|
46
|
+
expand: 'Show settings',
|
|
47
|
+
collapse: 'Hide settings',
|
|
48
|
+
overridden: 'Overridden',
|
|
49
|
+
reset: 'Reset',
|
|
50
|
+
invalidNumber: 'Enter a finite number.',
|
|
51
|
+
botId: 'Bot ID',
|
|
52
|
+
secret: 'Secret',
|
|
53
|
+
secretHint: 'Leave blank to keep the stored value. Save, then restart the process to open the WebSocket.',
|
|
54
|
+
secretConfigured: 'Configured',
|
|
55
|
+
secretUnset: 'Not configured',
|
|
56
|
+
allowFrom: 'Allowed sender userids',
|
|
57
|
+
allowFromHint: 'Comma-separated. Empty allows everyone.',
|
|
58
|
+
agentTimeoutSec: 'Task timeout (seconds)',
|
|
59
|
+
agentTimeoutSecHint: 'Progress bar and remaining-time estimate.',
|
|
60
|
+
startHint: 'Placeholder while thinking',
|
|
61
|
+
startHintHint: 'First stream line after a message arrives.',
|
|
62
|
+
deniedMessage: 'Denied-sender reply',
|
|
63
|
+
deniedMessageHint: 'Sent when the userid is outside the allow-list.',
|
|
64
|
+
welcomeMessage: 'Welcome message',
|
|
65
|
+
welcomeMessageHint: 'Sent when a user opens the WeCom chat.',
|
|
66
|
+
provider: 'WeCom-only provider',
|
|
67
|
+
providerHint: 'Empty follows the GUI default model. Both provider and model must be set to override.',
|
|
68
|
+
model: 'WeCom-only model',
|
|
69
|
+
modelHint: 'Takes effect only together with provider.',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Chinese copy for the im-bridge card. */
|
|
73
|
+
export const zh: Record<ImBridgeLocaleKey, string> = {
|
|
74
|
+
title: '企业微信桥接',
|
|
75
|
+
description: '凭证、白名单、超时和企微专用模型覆盖。',
|
|
76
|
+
unsaved: '未保存',
|
|
77
|
+
readOnly: '当前文档不可写。',
|
|
78
|
+
saveFailed: '保存未生效,请修正后重试。',
|
|
79
|
+
save: '保存',
|
|
80
|
+
saving: '保存中…',
|
|
81
|
+
discard: '放弃',
|
|
82
|
+
expand: '展开设置',
|
|
83
|
+
collapse: '收起设置',
|
|
84
|
+
overridden: '已覆盖',
|
|
85
|
+
reset: '重置',
|
|
86
|
+
invalidNumber: '请输入有效数字。',
|
|
87
|
+
botId: 'Bot ID',
|
|
88
|
+
secret: 'Secret',
|
|
89
|
+
secretHint: '留空保留已存值。保存后需重启进程才会连 WebSocket。',
|
|
90
|
+
secretConfigured: '已配置',
|
|
91
|
+
secretUnset: '未配置',
|
|
92
|
+
allowFrom: '允许的发送者 userid',
|
|
93
|
+
allowFromHint: '逗号分隔;空 = 允许所有人。',
|
|
94
|
+
agentTimeoutSec: '单任务超时(秒)',
|
|
95
|
+
agentTimeoutSecHint: '动画进度条和剩余估算的基准。',
|
|
96
|
+
startHint: '开始处理时的占位提示',
|
|
97
|
+
startHintHint: '收到消息后推送的第一条流式文案。',
|
|
98
|
+
deniedMessage: '非白名单拒绝文案',
|
|
99
|
+
deniedMessageHint: '发送者不在白名单时回复。',
|
|
100
|
+
welcomeMessage: '进入会话欢迎语',
|
|
101
|
+
welcomeMessageHint: '用户打开企微会话时发送。',
|
|
102
|
+
provider: '企微专用 provider',
|
|
103
|
+
providerHint: '空 = 跟随 GUI 默认模型。须与 model 同时填写才覆盖。',
|
|
104
|
+
model: '企微专用 model',
|
|
105
|
+
modelHint: '仅在同时填写 provider 时生效。',
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
109
|
+
interface LocaleNamespaceMap {
|
|
110
|
+
'im-bridge': ImBridgeLocaleKey
|
|
111
|
+
}
|
|
112
|
+
}
|