dsh-oauth-login 0.2.4
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/INSTALL.md +59 -0
- package/INSTALL.zh.md +45 -0
- package/LICENSE +201 -0
- package/README.en.md +104 -0
- package/README.md +104 -0
- package/README.zh.md +3 -0
- package/SECURITY.md +33 -0
- package/cordis.patch.yml +5 -0
- package/docs/network-proxy.md +45 -0
- package/docs/openrouter-sync.md +84 -0
- package/docs/screenshots/01-providers-signed-out.png +0 -0
- package/docs/screenshots/03-copilot-signing-in.png +0 -0
- package/docs/screenshots/04-mixed-live-states.png +0 -0
- package/docs/screenshots/05-copilot-sign-in.gif +0 -0
- package/docs/screenshots/06-cli-status-store.png +0 -0
- package/lib/bin.d.ts +5 -0
- package/lib/bin.js +2132 -0
- package/lib/client.js +1915 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +482 -0
- package/lib/index.js +3105 -0
- package/lib/invariant.d.ts +7 -0
- package/lib/invariant.js +8 -0
- package/package.json +180 -0
- package/src/adapter.ts +207 -0
- package/src/auth-routes.ts +654 -0
- package/src/auth.ts +76 -0
- package/src/bin.ts +185 -0
- package/src/catalog.ts +129 -0
- package/src/client/OpenRouterSyncStatus.tsx +66 -0
- package/src/client/PiLoginSettings.tsx +470 -0
- package/src/client/ProxySettings.tsx +123 -0
- package/src/client/SearchableModelSelect.tsx +595 -0
- package/src/client/draft-input.ts +12 -0
- package/src/client/index.tsx +71 -0
- package/src/client/locales.ts +176 -0
- package/src/client/login-input-copy.ts +34 -0
- package/src/client/login-window.ts +24 -0
- package/src/client/model-filter.ts +45 -0
- package/src/client/openrouter-store.ts +122 -0
- package/src/client/proxy-draft.ts +57 -0
- package/src/extra-models.ts +201 -0
- package/src/hosted-capture.ts +41 -0
- package/src/hosted-images.ts +154 -0
- package/src/http.ts +241 -0
- package/src/ids.ts +17 -0
- package/src/index.ts +183 -0
- package/src/invariant.ts +12 -0
- package/src/model-error-hint.ts +98 -0
- package/src/native-tools.ts +442 -0
- package/src/oauth-refresh.ts +49 -0
- package/src/openrouter-catalog.ts +245 -0
- package/src/openrouter-free.ts +76 -0
- package/src/openrouter-models.ts +155 -0
- package/src/openrouter-types.ts +40 -0
- package/src/plugin-config.ts +30 -0
- package/src/provider.ts +137 -0
- package/src/proxy-config.ts +61 -0
- package/src/proxy-store.ts +45 -0
- package/src/proxy-transport.ts +220 -0
- package/src/redact.ts +22 -0
- package/src/responses-tap.ts +97 -0
- package/src/session.ts +104 -0
- package/src/store.ts +234 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SearchableModelSelect: plugin-owned shadow of the composer model seat
|
|
3
|
+
* (`conversation.input.model`, registered at priority -1 so it renders over
|
|
4
|
+
* the shipped ModelSelect). Behavior mirrors the official two-level menu —
|
|
5
|
+
* root rows drill into the model list and the effort list over the SAME
|
|
6
|
+
* shared per-session directory — and adds one thing the shipped seat lacks:
|
|
7
|
+
* a local search box on the model pane that filters provider groups by model
|
|
8
|
+
* name/id or provider name while typing. Selection still submits through the
|
|
9
|
+
* injected `select` face, so the host stays the single fact source.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
|
|
13
|
+
type KeyboardEvent, type FocusEvent,
|
|
14
|
+
} from 'react'
|
|
15
|
+
import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'
|
|
16
|
+
import type { ModelDirectoryState as DirectoryState, ModelSelectInjected } from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
17
|
+
import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
18
|
+
import { filterGroups } from './model-filter.ts'
|
|
19
|
+
import type { FilterGroup } from './model-filter.ts'
|
|
20
|
+
import type { OpenRouterCatalogClient } from './openrouter-store.ts'
|
|
21
|
+
import { OpenRouterSyncStatus } from './OpenRouterSyncStatus.tsx'
|
|
22
|
+
import { OPENROUTER_ROUTE } from '../openrouter-types.ts'
|
|
23
|
+
|
|
24
|
+
/** Which pane the dropdown shows. */
|
|
25
|
+
type Pane = 'root' | 'model' | 'effort'
|
|
26
|
+
|
|
27
|
+
/** One effort row; undefined effort preserves the provider default. */
|
|
28
|
+
interface EffortChoice {
|
|
29
|
+
key: string
|
|
30
|
+
effort: string | undefined
|
|
31
|
+
label: string
|
|
32
|
+
description?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Component props: owner share + injected face + both translators. */
|
|
36
|
+
export interface SearchableModelSelectInjected extends ModelSelectInjected {
|
|
37
|
+
catalog: OpenRouterCatalogClient
|
|
38
|
+
/** Plugin `model-search` namespace translate (search box copy). */
|
|
39
|
+
ts: TranslateNS<'model-search'>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type SearchableModelSelectProps = PropsRuntime<'conversation.input.model'>
|
|
43
|
+
& PropsLocale<'model'>
|
|
44
|
+
& InjectFace<SearchableModelSelectInjected>
|
|
45
|
+
|
|
46
|
+
const STYLE_ID = 'dsh-oauth-model-search-theme'
|
|
47
|
+
|
|
48
|
+
/** Theme-token styles mirroring the shipped seat's menu vocabulary. */
|
|
49
|
+
const SEARCH_CSS = `
|
|
50
|
+
.dsh-oauth-ms-root { position:relative; min-width:0; }
|
|
51
|
+
.dsh-oauth-ms-trigger {
|
|
52
|
+
display:flex; align-items:center; gap:4px; min-width:0;
|
|
53
|
+
max-width:min(360px,45cqw); height:28px; padding:0 4px 0 8px;
|
|
54
|
+
border:none; border-radius:24px; outline:none; background:transparent;
|
|
55
|
+
color:var(--dsw-alias-label-secondary); font-size:13px; line-height:20px;
|
|
56
|
+
font-weight:500; cursor:pointer;
|
|
57
|
+
}
|
|
58
|
+
.dsh-oauth-ms-trigger:hover:not(:disabled) { background:var(--dsw-alias-interactive-bg-hover); }
|
|
59
|
+
.dsh-oauth-ms-trigger:focus-visible { box-shadow:0 0 0 2px var(--dsw-alias-border-l3); }
|
|
60
|
+
.dsh-oauth-ms-trigger:disabled { color:var(--dsw-alias-label-dimmed); cursor:default; }
|
|
61
|
+
.dsh-oauth-ms-triggerLabel { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
62
|
+
.dsh-oauth-ms-triggerEffort { flex:0 0 auto; color:var(--dsw-alias-label-caption); }
|
|
63
|
+
.dsh-oauth-ms-chevron { flex:0 0 auto; color:var(--dsw-alias-label-caption); transition:transform 120ms ease; }
|
|
64
|
+
.dsh-oauth-ms-chevronOpen { transform:rotate(180deg); }
|
|
65
|
+
@media (prefers-reduced-motion: reduce) { .dsh-oauth-ms-chevron { transition:none; } }
|
|
66
|
+
.dsh-oauth-ms-menu {
|
|
67
|
+
position:absolute; right:0; bottom:calc(100% + 8px); z-index:20;
|
|
68
|
+
display:flex; flex-direction:column;
|
|
69
|
+
width:max-content; min-width:min(240px,calc(100vw - 32px));
|
|
70
|
+
max-width:min(420px,calc(100vw - 32px));
|
|
71
|
+
max-height:min(360px,calc(100vh - 96px)); overflow:hidden; padding:4px;
|
|
72
|
+
border:1px solid var(--dsw-alias-border-inverted); border-radius:12px;
|
|
73
|
+
background:var(--dsw-specific-menu); box-shadow:var(--dsw-shadow-lv3);
|
|
74
|
+
color:var(--dsw-alias-label-primary);
|
|
75
|
+
--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);
|
|
76
|
+
--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);
|
|
77
|
+
}
|
|
78
|
+
.dsh-oauth-ms-status,.dsh-oauth-ms-empty { padding:10px; color:var(--dsw-alias-label-tertiary); font-size:13px; line-height:20px; }
|
|
79
|
+
.dsh-oauth-ms-errorStrip {
|
|
80
|
+
display:flex; align-items:flex-start; justify-content:space-between; gap:8px;
|
|
81
|
+
margin-bottom:4px; padding:7px 8px; border-radius:8px;
|
|
82
|
+
background:var(--dsw-alias-interactive-bg-hover-danger);
|
|
83
|
+
color:var(--dsw-alias-state-error-primary); font-size:12px; line-height:18px;
|
|
84
|
+
}
|
|
85
|
+
.dsh-oauth-ms-warning {
|
|
86
|
+
display:flex; align-items:flex-start; justify-content:space-between; gap:8px;
|
|
87
|
+
margin-bottom:4px; padding:7px 8px; border-radius:8px;
|
|
88
|
+
background:var(--dsw-alias-bg-module-platform);
|
|
89
|
+
color:var(--dsw-alias-state-warn-label); font-size:12px; line-height:18px;
|
|
90
|
+
}
|
|
91
|
+
.dsh-oauth-ms-retry { flex:0 0 auto; padding:0; border:none; background:transparent; color:inherit; font:inherit; font-weight:600; cursor:pointer; }
|
|
92
|
+
.dsh-oauth-ms-search {
|
|
93
|
+
margin:2px 2px 4px; padding:6px 8px; border:1px solid var(--dsw-alias-border-inverted);
|
|
94
|
+
border-radius:8px; background:transparent; font-size:13px;
|
|
95
|
+
color:var(--dsw-alias-label-primary); outline:none;
|
|
96
|
+
}
|
|
97
|
+
.dsh-oauth-ms-search:focus-visible { box-shadow:0 0 0 2px var(--dsw-alias-border-l3); }
|
|
98
|
+
.dsh-oauth-ms-groups { min-height:0; overflow-y:auto; }
|
|
99
|
+
.dsh-oauth-ms-group + .dsh-oauth-ms-group { margin-top:4px; }
|
|
100
|
+
.dsh-oauth-ms-groupTitle {
|
|
101
|
+
position:sticky; top:0; z-index:1; padding:5px 8px 3px;
|
|
102
|
+
background:var(--dsw-specific-menu); color:var(--dsw-alias-label-tertiary);
|
|
103
|
+
font-size:12px; line-height:18px; font-weight:500;
|
|
104
|
+
}
|
|
105
|
+
.dsh-oauth-ms-option {
|
|
106
|
+
box-sizing:border-box; display:flex; align-items:center; gap:8px;
|
|
107
|
+
width:auto; min-width:100%; min-height:38px; padding:6px 8px;
|
|
108
|
+
border:none; border-radius:10px; outline:none; background:transparent;
|
|
109
|
+
color:inherit; text-align:left; cursor:pointer;
|
|
110
|
+
}
|
|
111
|
+
.dsh-oauth-ms-option:hover:not(:disabled),.dsh-oauth-ms-option:focus-visible { background:var(--dsw-alias-interactive-bg-hover); }
|
|
112
|
+
.dsh-oauth-ms-option:disabled { color:var(--dsw-alias-label-dimmed); cursor:default; }
|
|
113
|
+
.dsh-oauth-ms-optionCopy { display:flex; flex:1; flex-direction:column; min-width:0; }
|
|
114
|
+
.dsh-oauth-ms-modelName { overflow:hidden; color:inherit; font-size:14px; line-height:20px; font-weight:500; text-overflow:ellipsis; white-space:nowrap; }
|
|
115
|
+
.dsh-oauth-ms-description { overflow:hidden; color:var(--dsw-alias-label-tertiary); font-size:12px; line-height:18px; text-overflow:ellipsis; white-space:nowrap; }
|
|
116
|
+
.dsh-oauth-ms-check { display:grid; place-items:center; flex:0 0 18px; color:var(--dsw-alias-label-primary); }
|
|
117
|
+
.dsh-oauth-ms-cell {
|
|
118
|
+
box-sizing:border-box; display:flex; align-items:center; gap:8px;
|
|
119
|
+
width:auto; min-width:100%; height:40px; padding:0 10px;
|
|
120
|
+
border:none; border-radius:10px; background:transparent;
|
|
121
|
+
color:var(--dsw-alias-label-primary); font-size:14px; line-height:22px;
|
|
122
|
+
cursor:pointer; text-align:left;
|
|
123
|
+
}
|
|
124
|
+
.dsh-oauth-ms-cell:hover { background:var(--dsw-alias-interactive-bg-hover); }
|
|
125
|
+
.dsh-oauth-ms-cellLabel { flex:0 0 auto; white-space:nowrap; }
|
|
126
|
+
.dsh-oauth-ms-cellValue { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:right; color:var(--dsw-alias-label-tertiary); }
|
|
127
|
+
.dsh-oauth-ms-cellChevron { flex:0 0 auto; color:var(--dsw-alias-label-tertiary); }
|
|
128
|
+
.dsh-oauth-ms-filters { display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin:2px 4px 4px; }
|
|
129
|
+
.dsh-oauth-ms-filter { min-height:28px;padding:3px 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:16px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer; }
|
|
130
|
+
.dsh-oauth-ms-filter[aria-pressed="true"] { background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3); }
|
|
131
|
+
.dsh-oauth-ms-filter:focus-visible { outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px; }
|
|
132
|
+
.dsh-oauth-ms-meta { display:flex;align-items:center;flex-wrap:wrap;gap:4px 8px;margin-top:3px;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px; }
|
|
133
|
+
.dsh-oauth-ms-free { padding:1px 6px;border-radius:4px;color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 50%,var(--dsw-alias-label-primary));background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent); }
|
|
134
|
+
.dsh-oauth-ms-cached { color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-interactive-bg-hover); }
|
|
135
|
+
.dsh-oauth-ms-ended { color:color-mix(in srgb,var(--dsw-alias-state-warn-label) 50%,var(--dsw-alias-label-primary));white-space:normal; }
|
|
136
|
+
.dsh-oauth-ms-help { padding:4px 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);white-space:normal; }
|
|
137
|
+
@media (max-width:480px) { .dsh-oauth-ms-menu { width:calc(100vw - 32px);max-height:min(520px,calc(100dvh - 120px)); } .dsh-oauth-ms-option { min-height:44px; } }
|
|
138
|
+
`
|
|
139
|
+
|
|
140
|
+
function ensureStyles(): void {
|
|
141
|
+
if (typeof document === 'undefined') return
|
|
142
|
+
const existing = document.getElementById(STYLE_ID)
|
|
143
|
+
if (existing !== null) {
|
|
144
|
+
existing.textContent = SEARCH_CSS
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const style = document.createElement('style')
|
|
148
|
+
style.id = STYLE_ID
|
|
149
|
+
style.textContent = SEARCH_CSS
|
|
150
|
+
document.head.appendChild(style)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Small inline chevron-down glyph (avoids importing host primitives). */
|
|
154
|
+
function Chevron({ className }: { className: string }) {
|
|
155
|
+
return (
|
|
156
|
+
<svg className={className} width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
157
|
+
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
158
|
+
</svg>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Small inline chevron-right glyph. */
|
|
163
|
+
function ChevronRight({ className }: { className: string }) {
|
|
164
|
+
return (
|
|
165
|
+
<svg className={className} width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
166
|
+
<path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
167
|
+
</svg>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Render the searchable composer model seat.
|
|
173
|
+
* @param props - see {@link SearchableModelSelectProps}.
|
|
174
|
+
* @returns the trigger and, while open, the search-enabled two-level menu.
|
|
175
|
+
*/
|
|
176
|
+
export function SearchableModelSelect(
|
|
177
|
+
{ locked, available, directory, catalog, load, select, t, ts }: SearchableModelSelectProps,
|
|
178
|
+
) {
|
|
179
|
+
const state = useSyncExternalStore(
|
|
180
|
+
fn => directory.subscribe(fn),
|
|
181
|
+
() => directory.getSnapshot(),
|
|
182
|
+
) as DirectoryState
|
|
183
|
+
const catalogState = useSyncExternalStore(catalog.subscribe, catalog.getSnapshot)
|
|
184
|
+
const metadata = useMemo(() => new Map(catalogState.data?.models.map(model => [model.id, model]) ?? []), [catalogState.data])
|
|
185
|
+
const freeModels = useMemo(() => new Set([...metadata.values()].filter(model => model.priceStatus === 'free').map(model => model.id)), [metadata])
|
|
186
|
+
const [freeOnly, setFreeOnly] = useState(false)
|
|
187
|
+
const [open, setOpen] = useState(false)
|
|
188
|
+
const [pane, setPane] = useState<Pane>('root')
|
|
189
|
+
const [query, setQuery] = useState('')
|
|
190
|
+
const lastActionRef = useRef<'load' | 'select'>('load')
|
|
191
|
+
const [notice, setNotice] = useState<{ seq: number; text: string } | null>(null)
|
|
192
|
+
const noticeSeq = useRef(0)
|
|
193
|
+
const noticeTimer = useRef<number | undefined>(undefined)
|
|
194
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
195
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
196
|
+
const searchRef = useRef<HTMLInputElement | null>(null)
|
|
197
|
+
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
|
198
|
+
const id = useId()
|
|
199
|
+
|
|
200
|
+
useEffect(() => { ensureStyles() }, [])
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (open && pane === 'model') searchRef.current?.focus()
|
|
203
|
+
}, [open, pane])
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (catalogState.data?.connected === false) setFreeOnly(false)
|
|
206
|
+
}, [catalogState.data?.connected])
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (available && catalogState.data?.lastUpdatedAt != null) load()
|
|
209
|
+
}, [available, catalogState.data?.lastUpdatedAt, load])
|
|
210
|
+
useEffect(() => () => { window.clearTimeout(noticeTimer.current) }, [])
|
|
211
|
+
|
|
212
|
+
const choices = useMemo(() => state.groups.flatMap(group =>
|
|
213
|
+
group.models.map(model => ({
|
|
214
|
+
group,
|
|
215
|
+
model,
|
|
216
|
+
selection: {
|
|
217
|
+
provider: group.id,
|
|
218
|
+
model: model.id,
|
|
219
|
+
...model.reasoning?.defaultEffort === undefined
|
|
220
|
+
? {}
|
|
221
|
+
: { reasoningEffort: model.reasoning.defaultEffort },
|
|
222
|
+
} satisfies ModelSelection,
|
|
223
|
+
}))), [state.groups])
|
|
224
|
+
const current = state.current
|
|
225
|
+
const selectedIndex = current === null
|
|
226
|
+
? -1
|
|
227
|
+
: choices.findIndex(c => c.selection.provider === current.provider && c.selection.model === current.model)
|
|
228
|
+
const currentChoice = choices[selectedIndex]
|
|
229
|
+
const reasoning = currentChoice?.model.reasoning
|
|
230
|
+
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort
|
|
231
|
+
const effortLabel = reasoning === undefined
|
|
232
|
+
? undefined
|
|
233
|
+
: effectiveEffort === undefined
|
|
234
|
+
? t('effort.providerDefault')
|
|
235
|
+
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
|
|
236
|
+
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
|
|
237
|
+
? []
|
|
238
|
+
: [
|
|
239
|
+
...reasoning.defaultEffort === undefined
|
|
240
|
+
? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
|
|
241
|
+
: [],
|
|
242
|
+
...reasoning.efforts.map(effort => ({
|
|
243
|
+
key: `effort:${effort.id}`,
|
|
244
|
+
effort: effort.id,
|
|
245
|
+
label: effort.name,
|
|
246
|
+
...effort.description === undefined ? {} : { description: effort.description },
|
|
247
|
+
})),
|
|
248
|
+
], [reasoning, t])
|
|
249
|
+
const busy = state.status === 'selecting'
|
|
250
|
+
|
|
251
|
+
const filteredGroups = useMemo<readonly FilterGroup[]>(
|
|
252
|
+
() => filterGroups(state.groups, query, freeOnly ? freeModels : undefined),
|
|
253
|
+
[state.groups, query, freeOnly, freeModels])
|
|
254
|
+
const filteredCount = useMemo(
|
|
255
|
+
() => filteredGroups.reduce((sum, group) => sum + group.models.length, 0),
|
|
256
|
+
[filteredGroups])
|
|
257
|
+
|
|
258
|
+
const reload = (): void => {
|
|
259
|
+
lastActionRef.current = 'load'
|
|
260
|
+
load()
|
|
261
|
+
void catalog.load()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Mount-time load resolves the trigger label; every open refreshes.
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (available) {
|
|
267
|
+
lastActionRef.current = 'load'
|
|
268
|
+
load()
|
|
269
|
+
}
|
|
270
|
+
}, [available, load])
|
|
271
|
+
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
if (!open) return
|
|
274
|
+
const closeOutside = (event: MouseEvent): void => {
|
|
275
|
+
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
|
|
276
|
+
}
|
|
277
|
+
document.addEventListener('mousedown', closeOutside)
|
|
278
|
+
return () => { document.removeEventListener('mousedown', closeOutside) }
|
|
279
|
+
}, [open])
|
|
280
|
+
|
|
281
|
+
if (!available) return null
|
|
282
|
+
|
|
283
|
+
const show = (): void => {
|
|
284
|
+
setPane('root')
|
|
285
|
+
setQuery('')
|
|
286
|
+
setNotice(null)
|
|
287
|
+
setOpen(true)
|
|
288
|
+
reload()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const close = (restoreFocus = false): void => {
|
|
292
|
+
setOpen(false)
|
|
293
|
+
setPane('root')
|
|
294
|
+
setQuery('')
|
|
295
|
+
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const moveFocus = (offset: number): void => {
|
|
299
|
+
const items = itemRefs.current.filter(item => item !== null && !item.disabled)
|
|
300
|
+
if (items.length === 0) return
|
|
301
|
+
const active = items.findIndex(item => item === document.activeElement)
|
|
302
|
+
const next = (Math.max(active, 0) + offset + items.length) % items.length
|
|
303
|
+
items[next]?.focus()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
307
|
+
if (event.key === 'Escape' && open) {
|
|
308
|
+
event.preventDefault()
|
|
309
|
+
if (pane !== 'root') setPane('root')
|
|
310
|
+
else close(true)
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
if (!open) return
|
|
314
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
315
|
+
event.preventDefault()
|
|
316
|
+
moveFocus(event.key === 'ArrowDown' ? 1 : -1)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
|
321
|
+
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
|
|
322
|
+
close()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const settleSelection = (accepted: boolean): void => {
|
|
326
|
+
if (accepted) {
|
|
327
|
+
if (rootRef.current !== null) close(true)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
const message = directory.getSnapshot().error
|
|
331
|
+
if (message !== null) {
|
|
332
|
+
noticeSeq.current += 1
|
|
333
|
+
window.clearTimeout(noticeTimer.current)
|
|
334
|
+
setNotice({ seq: noticeSeq.current, text: t('error.action', { message }) })
|
|
335
|
+
noticeTimer.current = window.setTimeout(() => { setNotice(null) }, 4000)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const choose = (selection: ModelSelection): void => {
|
|
340
|
+
const info = selection.provider === OPENROUTER_ROUTE ? metadata.get(selection.model) : undefined
|
|
341
|
+
if (info?.freeOnly && info.priceStatus !== 'free') return
|
|
342
|
+
const current = state.current
|
|
343
|
+
if (current?.provider === selection.provider && current.model === selection.model) {
|
|
344
|
+
close(true)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
lastActionRef.current = 'select'
|
|
348
|
+
void select(selection).then(settleSelection)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const chooseEffort = (effort: string | undefined): void => {
|
|
352
|
+
if (state.current === null) return
|
|
353
|
+
if (effectiveEffort === effort) {
|
|
354
|
+
close(true)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
const selection: ModelSelection = {
|
|
358
|
+
provider: state.current.provider,
|
|
359
|
+
model: state.current.model,
|
|
360
|
+
...effort === undefined ? {} : { reasoningEffort: effort },
|
|
361
|
+
}
|
|
362
|
+
lastActionRef.current = 'select'
|
|
363
|
+
void select(selection).then(settleSelection)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Search-input keyboard: ↓ enters the list, Enter picks the first match, Escape clears then backs out. */
|
|
367
|
+
const onSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
|
368
|
+
if (event.key === 'ArrowDown') {
|
|
369
|
+
event.preventDefault()
|
|
370
|
+
event.stopPropagation()
|
|
371
|
+
itemRefs.current.find(item => item !== null)?.focus()
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
if (event.key === 'Enter') {
|
|
375
|
+
event.preventDefault()
|
|
376
|
+
const first = filteredGroups[0]?.models[0]
|
|
377
|
+
if (first !== undefined) choose({ provider: filteredGroups[0].id, model: first.id })
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
if (event.key === 'Escape') {
|
|
381
|
+
event.stopPropagation()
|
|
382
|
+
if (query !== '') setQuery('')
|
|
383
|
+
// Non-Escape propagation lets the root handler back out of the pane.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const modelLabel = currentChoice?.model.name ?? state.current?.model ?? t('trigger.fallback')
|
|
388
|
+
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
|
389
|
+
const triggerAria = currentChoice === undefined
|
|
390
|
+
? t('trigger.selectAria')
|
|
391
|
+
: effortLabel === undefined
|
|
392
|
+
? t('trigger.aria', { model: modelLabel })
|
|
393
|
+
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })
|
|
394
|
+
itemRefs.current = []
|
|
395
|
+
let itemIndex = 0
|
|
396
|
+
const itemRef = () => {
|
|
397
|
+
const at = itemIndex++
|
|
398
|
+
return (node: HTMLButtonElement | null) => { itemRefs.current[at] = node }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<div ref={rootRef} className="dsh-oauth-ms-root" onKeyDown={onRootKeyDown} onBlur={onBlur}>
|
|
403
|
+
<button
|
|
404
|
+
ref={triggerRef}
|
|
405
|
+
type="button"
|
|
406
|
+
className="dsh-oauth-ms-trigger"
|
|
407
|
+
aria-label={triggerAria}
|
|
408
|
+
aria-haspopup="menu"
|
|
409
|
+
aria-expanded={open}
|
|
410
|
+
aria-controls={open ? `${id}-menu` : undefined}
|
|
411
|
+
title={triggerLabel}
|
|
412
|
+
disabled={locked}
|
|
413
|
+
onClick={() => {
|
|
414
|
+
if (open) close()
|
|
415
|
+
else show()
|
|
416
|
+
}}
|
|
417
|
+
>
|
|
418
|
+
<span className="dsh-oauth-ms-triggerLabel">{modelLabel}</span>
|
|
419
|
+
{effortLabel !== undefined && <span className="dsh-oauth-ms-triggerEffort">{effortLabel}</span>}
|
|
420
|
+
<Chevron className={`dsh-oauth-ms-chevron${open ? ' dsh-oauth-ms-chevronOpen' : ''}`} />
|
|
421
|
+
</button>
|
|
422
|
+
|
|
423
|
+
{open && (
|
|
424
|
+
<div
|
|
425
|
+
id={`${id}-menu`}
|
|
426
|
+
className="dsh-oauth-ms-menu"
|
|
427
|
+
role="menu"
|
|
428
|
+
aria-label={t('menu.aria')}
|
|
429
|
+
aria-busy={state.status === 'loading' || busy}
|
|
430
|
+
>
|
|
431
|
+
{pane === 'root' && (
|
|
432
|
+
<>
|
|
433
|
+
<button ref={itemRef()} type="button" role="menuitem" className="dsh-oauth-ms-cell" onClick={() => { setPane('model') }}>
|
|
434
|
+
<span className="dsh-oauth-ms-cellLabel">{t('menu.model')}</span>
|
|
435
|
+
<span className="dsh-oauth-ms-cellValue">{modelLabel}</span>
|
|
436
|
+
<ChevronRight className="dsh-oauth-ms-cellChevron" />
|
|
437
|
+
</button>
|
|
438
|
+
{reasoning !== undefined && (
|
|
439
|
+
<button ref={itemRef()} type="button" role="menuitem" className="dsh-oauth-ms-cell" onClick={() => { setPane('effort') }}>
|
|
440
|
+
<span className="dsh-oauth-ms-cellLabel">{t('menu.effort')}</span>
|
|
441
|
+
<span className="dsh-oauth-ms-cellValue">{effortLabel}</span>
|
|
442
|
+
<ChevronRight className="dsh-oauth-ms-cellChevron" />
|
|
443
|
+
</button>
|
|
444
|
+
)}
|
|
445
|
+
</>
|
|
446
|
+
)}
|
|
447
|
+
|
|
448
|
+
{pane === 'model' && (
|
|
449
|
+
<>
|
|
450
|
+
<input
|
|
451
|
+
ref={searchRef}
|
|
452
|
+
type="text"
|
|
453
|
+
className="dsh-oauth-ms-search"
|
|
454
|
+
placeholder={ts('placeholder')}
|
|
455
|
+
aria-label={ts('aria')}
|
|
456
|
+
value={query}
|
|
457
|
+
autoComplete="off"
|
|
458
|
+
spellCheck={false}
|
|
459
|
+
onChange={event => { setQuery(event.target.value) }}
|
|
460
|
+
onKeyDown={onSearchKeyDown}
|
|
461
|
+
/>
|
|
462
|
+
{(catalogState.data?.connected || state.groups.some(group => group.id === OPENROUTER_ROUTE)) && <>
|
|
463
|
+
<div className="dsh-oauth-ms-filters" role="group" aria-label={ts('freeFilterAria')}>
|
|
464
|
+
<button type="button" className="dsh-oauth-ms-filter" aria-pressed={!freeOnly} onClick={() => { setFreeOnly(false) }}>{ts('all')}</button>
|
|
465
|
+
<button type="button" className="dsh-oauth-ms-filter" aria-pressed={freeOnly} onClick={() => { setFreeOnly(true) }}>{ts('freeOnly')} · {freeModels.size}</button>
|
|
466
|
+
</div>
|
|
467
|
+
<OpenRouterSyncStatus catalog={catalog} ts={ts} />
|
|
468
|
+
</>}
|
|
469
|
+
{state.status === 'loading' && <div className="dsh-oauth-ms-status">{t('status.loading')}</div>}
|
|
470
|
+
{state.error !== null && lastActionRef.current === 'load' && (
|
|
471
|
+
<div className="dsh-oauth-ms-errorStrip">
|
|
472
|
+
<span>{t('error.action', { message: state.error })}</span>
|
|
473
|
+
<button type="button" className="dsh-oauth-ms-retry" onClick={reload}>{t('retry')}</button>
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
{state.failures.map(failure => (
|
|
477
|
+
<div className="dsh-oauth-ms-warning" key={failure.id}>
|
|
478
|
+
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
|
|
479
|
+
<button type="button" className="dsh-oauth-ms-retry" onClick={reload}>{t('retry')}</button>
|
|
480
|
+
</div>
|
|
481
|
+
))}
|
|
482
|
+
<div className="dsh-oauth-ms-groups">
|
|
483
|
+
{filteredGroups.map((group) => {
|
|
484
|
+
const headingId = `${id}-${group.id}`
|
|
485
|
+
return (
|
|
486
|
+
<section role="group" aria-labelledby={headingId} className="dsh-oauth-ms-group" key={group.id}>
|
|
487
|
+
<div className="dsh-oauth-ms-groupTitle" id={headingId}>{group.name}</div>
|
|
488
|
+
{group.models.map(model => {
|
|
489
|
+
const selected = state.current?.provider === group.id && state.current.model === model.id
|
|
490
|
+
const info = group.id === OPENROUTER_ROUTE ? metadata.get(model.id) : undefined
|
|
491
|
+
const blocked = info?.freeOnly === true && info.priceStatus !== 'free'
|
|
492
|
+
const tokens = info === undefined ? '' : new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(info.contextWindow)
|
|
493
|
+
return (
|
|
494
|
+
<button
|
|
495
|
+
ref={itemRef()}
|
|
496
|
+
type="button"
|
|
497
|
+
role="menuitemradio"
|
|
498
|
+
aria-checked={selected}
|
|
499
|
+
className="dsh-oauth-ms-option"
|
|
500
|
+
key={model.id}
|
|
501
|
+
title={model.name}
|
|
502
|
+
disabled={busy || blocked}
|
|
503
|
+
onClick={() => { choose({ provider: group.id, model: model.id }) }}
|
|
504
|
+
>
|
|
505
|
+
<span className="dsh-oauth-ms-optionCopy">
|
|
506
|
+
<span className="dsh-oauth-ms-modelName">{model.name}</span>
|
|
507
|
+
{model.description !== undefined && (
|
|
508
|
+
<span className="dsh-oauth-ms-description">{model.description}</span>
|
|
509
|
+
)}
|
|
510
|
+
{info !== undefined && (
|
|
511
|
+
<span className="dsh-oauth-ms-meta">
|
|
512
|
+
{info.priceStatus === 'free' && <span className={'dsh-oauth-ms-free' + (catalogState.data?.stale ? ' dsh-oauth-ms-cached' : '')}>
|
|
513
|
+
{ts(catalogState.data?.stale ? 'freeCached' : 'free')}
|
|
514
|
+
</span>}
|
|
515
|
+
{blocked && <span className="dsh-oauth-ms-ended">{ts(info.priceStatus === 'paid' ? 'freeEnded' : 'freeUnknown')}</span>}
|
|
516
|
+
<span>{ts(info.tools ? 'tools' : 'chatOnly')}</span>
|
|
517
|
+
<span>{ts('context', { tokens })}</span>
|
|
518
|
+
</span>
|
|
519
|
+
)}
|
|
520
|
+
</span>
|
|
521
|
+
<span className="dsh-oauth-ms-check">
|
|
522
|
+
{selected
|
|
523
|
+
? (
|
|
524
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
525
|
+
<path d="M3 8.5l3.2 3L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
526
|
+
</svg>
|
|
527
|
+
)
|
|
528
|
+
: null}
|
|
529
|
+
</span>
|
|
530
|
+
</button>
|
|
531
|
+
)
|
|
532
|
+
})}
|
|
533
|
+
</section>
|
|
534
|
+
)
|
|
535
|
+
})}
|
|
536
|
+
</div>
|
|
537
|
+
{state.status === 'ready' && state.groups.length > 0 && filteredCount === 0 && (
|
|
538
|
+
<div className="dsh-oauth-ms-empty">{freeOnly ? ts('emptyFree') : ts('empty', { query: query.trim() })}</div>
|
|
539
|
+
)}
|
|
540
|
+
{state.status === 'ready' && state.groups.length === 0 && (
|
|
541
|
+
<div className="dsh-oauth-ms-empty">{t('empty.models')}</div>
|
|
542
|
+
)}
|
|
543
|
+
{freeOnly && <div className="dsh-oauth-ms-help">{ts('freeHelp')}</div>}
|
|
544
|
+
</>
|
|
545
|
+
)}
|
|
546
|
+
|
|
547
|
+
{pane === 'effort' && (
|
|
548
|
+
<>
|
|
549
|
+
{state.error !== null && lastActionRef.current === 'load' && (
|
|
550
|
+
<div className="dsh-oauth-ms-errorStrip">
|
|
551
|
+
<span>{t('error.action', { message: state.error })}</span>
|
|
552
|
+
<button type="button" className="dsh-oauth-ms-retry" onClick={reload}>{t('action.reload')}</button>
|
|
553
|
+
</div>
|
|
554
|
+
)}
|
|
555
|
+
{effortChoices.length === 0
|
|
556
|
+
? <div className="dsh-oauth-ms-empty">{t('empty.efforts')}</div>
|
|
557
|
+
: effortChoices.map(level => (
|
|
558
|
+
<button
|
|
559
|
+
ref={itemRef()}
|
|
560
|
+
type="button"
|
|
561
|
+
role="menuitemradio"
|
|
562
|
+
aria-checked={effectiveEffort === level.effort}
|
|
563
|
+
className="dsh-oauth-ms-option"
|
|
564
|
+
key={level.key}
|
|
565
|
+
disabled={busy}
|
|
566
|
+
onClick={() => { chooseEffort(level.effort) }}
|
|
567
|
+
>
|
|
568
|
+
<span className="dsh-oauth-ms-optionCopy">
|
|
569
|
+
<span className="dsh-oauth-ms-modelName">{level.label}</span>
|
|
570
|
+
{level.description !== undefined && (
|
|
571
|
+
<span className="dsh-oauth-ms-description">{level.description}</span>
|
|
572
|
+
)}
|
|
573
|
+
</span>
|
|
574
|
+
<span className="dsh-oauth-ms-check">
|
|
575
|
+
{effectiveEffort === level.effort
|
|
576
|
+
? (
|
|
577
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
578
|
+
<path d="M3 8.5l3.2 3L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
579
|
+
</svg>
|
|
580
|
+
)
|
|
581
|
+
: null}
|
|
582
|
+
</span>
|
|
583
|
+
</button>
|
|
584
|
+
))}
|
|
585
|
+
</>
|
|
586
|
+
)}
|
|
587
|
+
|
|
588
|
+
{notice !== null && (
|
|
589
|
+
<div role="alert" className="dsh-oauth-ms-errorStrip" key={notice.seq}>{notice.text}</div>
|
|
590
|
+
)}
|
|
591
|
+
</div>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
)
|
|
595
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Drafts = Record<string, string>
|
|
2
|
+
export type DraftUpdater = (current: Drafts) => Drafts
|
|
3
|
+
export type DraftSetter = (updater: DraftUpdater) => void
|
|
4
|
+
|
|
5
|
+
export function applyDraftChange(
|
|
6
|
+
providerId: string,
|
|
7
|
+
event: { currentTarget: { value: string } },
|
|
8
|
+
setDrafts: DraftSetter,
|
|
9
|
+
): void {
|
|
10
|
+
const value = event.currentTarget.value
|
|
11
|
+
setDrafts(current => ({ ...current, [providerId]: value }))
|
|
12
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Browser half: Pi login settings page + searchable composer model seat. */
|
|
2
|
+
|
|
3
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
4
|
+
import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types'
|
|
5
|
+
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
6
|
+
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
9
|
+
import type {} from '@deepseek-ai/dsh-client-ui-model-selection/client'
|
|
10
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
11
|
+
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
|
12
|
+
import { PiLoginSettings } from './PiLoginSettings.tsx'
|
|
13
|
+
import type { PiLoginSettingsInjected } from './PiLoginSettings.tsx'
|
|
14
|
+
import { SearchableModelSelect } from './SearchableModelSelect.tsx'
|
|
15
|
+
import { en, zh, searchEn, searchZh } from './locales.ts'
|
|
16
|
+
import type { PiLoginKey, ModelSearchKey } from './locales.ts'
|
|
17
|
+
import { OpenRouterCatalogClient } from './openrouter-store.ts'
|
|
18
|
+
|
|
19
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
20
|
+
interface LocaleNamespaceMap {
|
|
21
|
+
'settings.pi-login': PiLoginKey
|
|
22
|
+
'model-search': ModelSearchKey
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const name = 'dsh-oauth-login-client'
|
|
27
|
+
// directoryFor() resolves remote.session through the calling plugin's context.
|
|
28
|
+
export const inject = ['slots', 'locale', 'sessions', 'modelDirectories', 'remote', 'remote.session']
|
|
29
|
+
|
|
30
|
+
export function apply(ctx: ClientContext): void {
|
|
31
|
+
const catalog = new OpenRouterCatalogClient()
|
|
32
|
+
ctx.effect(() => () => catalog.dispose(), 'dsh-oauth-login: catalog metadata')
|
|
33
|
+
const searchNs = 'model-search'
|
|
34
|
+
ctx.effect(() => ctx.locale.register(searchNs, { zh: searchZh, en: searchEn }), 'dsh-oauth-login: model-search copy')
|
|
35
|
+
const ts = ctx.locale.bind(searchNs)
|
|
36
|
+
const namespace = 'settings.pi-login'
|
|
37
|
+
ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'dsh-oauth-login: settings copy')
|
|
38
|
+
const t = ctx.locale.bind(namespace) as PiLoginSettingsInjected['t']
|
|
39
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
40
|
+
name: 'settings.section',
|
|
41
|
+
id: 'pi-login',
|
|
42
|
+
order: 17,
|
|
43
|
+
label: () => t('nav'),
|
|
44
|
+
inject: (): PiLoginSettingsInjected => ({ t, ts, catalog }),
|
|
45
|
+
}, PiLoginSettings))
|
|
46
|
+
|
|
47
|
+
// Searchable shadow of the composer model seat. Priority -1 renders over
|
|
48
|
+
// the shipped ModelSelect (priority 0, lowest live entry wins); removing
|
|
49
|
+
// this plugin restores the stock selector with no host restart.
|
|
50
|
+
ctx.slots.inject('conversation.input.model', () => ctx.slots.register({
|
|
51
|
+
name: 'conversation.input.model',
|
|
52
|
+
locale: 'model',
|
|
53
|
+
priority: -1,
|
|
54
|
+
inject: (sessionId) => {
|
|
55
|
+
const directory = ctx.modelDirectories.directoryFor(sessionId)
|
|
56
|
+
const available = ctx.sessions.subagentAddress(sessionId) === undefined
|
|
57
|
+
return {
|
|
58
|
+
available,
|
|
59
|
+
catalog,
|
|
60
|
+
directory: directory.store,
|
|
61
|
+
load: () => {
|
|
62
|
+
if (available) directory.load().catch(() => { /* surfaced on the store */ })
|
|
63
|
+
},
|
|
64
|
+
select: (selection: ModelSelection) => available
|
|
65
|
+
? directory.select(selection).then(() => true, () => false)
|
|
66
|
+
: Promise.resolve(false),
|
|
67
|
+
ts,
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}, SearchableModelSelect))
|
|
71
|
+
}
|