@xp266/dshtui 0.1.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/bin/dshtui.js +65 -0
- package/cordis.patch.yml +81 -0
- package/lib/contract/index.d.mts +2 -0
- package/lib/contract/index.mjs +5 -0
- package/lib/dialog.d.mts +156 -0
- package/lib/dialog.mjs +3 -0
- package/lib/index-DqnaSXqP.d.mts +823 -0
- package/lib/index.d.mts +17 -0
- package/lib/index.mjs +11404 -0
- package/lib/prod-react-Dzof4qJx.mjs +4 -0
- package/lib/surface-DdxzIgIY.mjs +3126 -0
- package/lib/vendor.d.mts +3 -0
- package/lib/vendor.mjs +4 -0
- package/package.json +128 -0
- package/src/apply-theme.ts +8 -0
- package/src/boot-log.ts +35 -0
- package/src/chat/blocks.ts +41 -0
- package/src/chat/bridge.ts +1076 -0
- package/src/chat/builtin-tool-views.ts +53 -0
- package/src/chat/chat-face.ts +16 -0
- package/src/chat/chat-nodes.ts +53 -0
- package/src/chat/efforts.ts +4 -0
- package/src/chat/interactions.ts +329 -0
- package/src/chat/models.ts +263 -0
- package/src/chat/partial-json.ts +125 -0
- package/src/chat/presets.ts +26 -0
- package/src/chat/question-view.ts +106 -0
- package/src/chat/retry-status.ts +33 -0
- package/src/chat/session-groups.ts +34 -0
- package/src/chat/session-list.ts +311 -0
- package/src/chat/store.ts +636 -0
- package/src/chat/todo-view.ts +66 -0
- package/src/chat/tool-view.ts +311 -0
- package/src/chat/tool-views.ts +114 -0
- package/src/contract/index.ts +704 -0
- package/src/contract/upstream.ts +165 -0
- package/src/core/caret-nonce.ts +19 -0
- package/src/core/composer-layout.ts +65 -0
- package/src/core/edit.ts +152 -0
- package/src/core/field-view.ts +52 -0
- package/src/core/fields.ts +185 -0
- package/src/core/metrics.ts +26 -0
- package/src/core/paste.ts +149 -0
- package/src/core/segments.ts +112 -0
- package/src/core/text.ts +290 -0
- package/src/dialog.ts +11 -0
- package/src/env.ts +38 -0
- package/src/harness-home.ts +22 -0
- package/src/hot-theme.ts +56 -0
- package/src/index.tsx +185 -0
- package/src/kernel/registry.ts +102 -0
- package/src/kernel/surface.ts +33 -0
- package/src/log.ts +249 -0
- package/src/model/message.ts +89 -0
- package/src/model/selection.ts +63 -0
- package/src/performance-guard.ts +28 -0
- package/src/runtime/prod-react.ts +10 -0
- package/src/terminal/background.ts +66 -0
- package/src/terminal/capabilities.ts +54 -0
- package/src/terminal/clipboard-backends.ts +69 -0
- package/src/terminal/clipboard.ts +161 -0
- package/src/terminal/cursor-shape.ts +13 -0
- package/src/terminal/glyphs.ts +44 -0
- package/src/terminal/mouse.ts +154 -0
- package/src/terminal/probe.ts +61 -0
- package/src/terminal/screen.ts +349 -0
- package/src/theme-settings.ts +31 -0
- package/src/theme.ts +319 -0
- package/src/ui/app.tsx +454 -0
- package/src/ui/chrome/caps.tsx +9 -0
- package/src/ui/chrome/hint-service.ts +87 -0
- package/src/ui/chrome/input-status.ts +27 -0
- package/src/ui/chrome/key-gate.tsx +29 -0
- package/src/ui/chrome/status-bar.tsx +147 -0
- package/src/ui/contributions.ts +47 -0
- package/src/ui/dialog/defaults-dialog.tsx +152 -0
- package/src/ui/dialog/dialog-item.tsx +38 -0
- package/src/ui/dialog/dialog.tsx +386 -0
- package/src/ui/dialog/effort-dialog.tsx +45 -0
- package/src/ui/dialog/geometry.ts +131 -0
- package/src/ui/dialog/items.ts +124 -0
- package/src/ui/dialog/list-dialog.tsx +63 -0
- package/src/ui/dialog/models-dialog.tsx +203 -0
- package/src/ui/dialog/presets-dialog.tsx +46 -0
- package/src/ui/dialog/providers-dialog.tsx +313 -0
- package/src/ui/dialog/sessions-dialog.tsx +163 -0
- package/src/ui/dialog/sizes.ts +7 -0
- package/src/ui/dialog/status-lines.ts +11 -0
- package/src/ui/dialog/todo-dialog.tsx +29 -0
- package/src/ui/dialog/use-dialog-input.ts +186 -0
- package/src/ui/extension-point.ts +188 -0
- package/src/ui/home-logo.ts +132 -0
- package/src/ui/hooks/use-async-action.ts +25 -0
- package/src/ui/hooks/use-async-list.ts +71 -0
- package/src/ui/hooks/use-caret.ts +24 -0
- package/src/ui/hooks/use-chat-events.ts +153 -0
- package/src/ui/hooks/use-mouse-selection.ts +352 -0
- package/src/ui/hooks/use-scroll.ts +49 -0
- package/src/ui/hooks/use-terminal-size.ts +20 -0
- package/src/ui/input/commands.ts +118 -0
- package/src/ui/input/composer-bus.ts +14 -0
- package/src/ui/input/composer-fields.ts +111 -0
- package/src/ui/input/composer-keys.ts +21 -0
- package/src/ui/input/composer-paste.ts +20 -0
- package/src/ui/input/input-bar.tsx +279 -0
- package/src/ui/input/use-composer.ts +495 -0
- package/src/ui/key-arbiter.ts +37 -0
- package/src/ui/keymap.ts +15 -0
- package/src/ui/layout-service.ts +77 -0
- package/src/ui/message/layout.ts +763 -0
- package/src/ui/message/md/block.ts +247 -0
- package/src/ui/message/md/engine.ts +430 -0
- package/src/ui/message/md/extensions.ts +58 -0
- package/src/ui/message/md/highlight.ts +626 -0
- package/src/ui/message/md/index.ts +14 -0
- package/src/ui/message/md/inline.ts +97 -0
- package/src/ui/message/md/palette.ts +87 -0
- package/src/ui/message/message-list.tsx +69 -0
- package/src/ui/message/message-row.tsx +137 -0
- package/src/ui/message/message-views.ts +16 -0
- package/src/ui/message/renderers.ts +39 -0
- package/src/ui/message/tool-diff.ts +188 -0
- package/src/ui/message/warmup.ts +41 -0
- package/src/ui/message/wave.ts +98 -0
- package/src/ui/overlay.ts +22 -0
- package/src/ui/panels/approval-panel.tsx +193 -0
- package/src/ui/panels/exports.ts +3 -0
- package/src/ui/panels/question-model.ts +424 -0
- package/src/ui/panels/question-panel.tsx +154 -0
- package/src/ui/panels/surface.tsx +106 -0
- package/src/ui/panels-builtin.tsx +86 -0
- package/src/ui/pointer/builtins.ts +281 -0
- package/src/ui/pointer/registry.ts +25 -0
- package/src/ui/region.tsx +31 -0
- package/src/ui/selection/service.ts +92 -0
- package/src/ui/selection-registry.ts +88 -0
- package/src/ui/selection.tsx +148 -0
- package/src/ui/spinner-tick.tsx +24 -0
- package/src/ui/use-available-windows.ts +31 -0
- package/src/ui/widgets/actions.tsx +84 -0
- package/src/ui/widgets/button.tsx +57 -0
- package/src/ui/widgets/checkbox.tsx +58 -0
- package/src/ui/widgets/header.tsx +36 -0
- package/src/ui/widgets/index.ts +8 -0
- package/src/ui/widgets/input.tsx +76 -0
- package/src/ui/widgets/registry.ts +32 -0
- package/src/ui/widgets/search.tsx +59 -0
- package/src/ui/widgets/select.tsx +97 -0
- package/src/ui/widgets/static.tsx +37 -0
- package/src/ui/widgets/types.ts +1 -0
- package/src/ui/window-services-bridge.ts +71 -0
- package/src/ui/window-services.ts +20 -0
- package/src/ui/windows/models-window.tsx +15 -0
- package/src/ui/windows/providers-window.tsx +14 -0
- package/src/ui/windows/service-window.tsx +18 -0
- package/src/ui/windows/sessions-window.tsx +25 -0
- package/src/ui/windows-builtin.tsx +94 -0
- package/src/ui/windows.ts +18 -0
- package/src/vendor.ts +12 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Ref } from 'react'
|
|
2
|
+
import { errorLine, loadingLine } from './status-lines.ts'
|
|
3
|
+
import { useAsyncList } from '../hooks/use-async-list.ts'
|
|
4
|
+
import { Dialog } from './dialog.tsx'
|
|
5
|
+
import type { DialogFooterLine, DialogHandle, DialogItem, DialogRow } from './dialog.tsx'
|
|
6
|
+
|
|
7
|
+
export interface ListDialogProps<T> {
|
|
8
|
+
ref?: Ref<DialogHandle>
|
|
9
|
+
title: string
|
|
10
|
+
width: number
|
|
11
|
+
maxHeight: number
|
|
12
|
+
load: () => Promise<T[]>
|
|
13
|
+
search?: boolean
|
|
14
|
+
searchRight?: boolean
|
|
15
|
+
labelOf(item: T): string
|
|
16
|
+
rightOf?(item: T): string | undefined
|
|
17
|
+
onSelect(item: T): void
|
|
18
|
+
onClose(): void
|
|
19
|
+
footerLines?: DialogFooterLine[]
|
|
20
|
+
errors?: DialogFooterLine[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ListDialog<T>({
|
|
24
|
+
ref,
|
|
25
|
+
title,
|
|
26
|
+
width,
|
|
27
|
+
maxHeight,
|
|
28
|
+
load,
|
|
29
|
+
search = false,
|
|
30
|
+
searchRight = false,
|
|
31
|
+
labelOf,
|
|
32
|
+
rightOf,
|
|
33
|
+
onSelect,
|
|
34
|
+
onClose,
|
|
35
|
+
footerLines,
|
|
36
|
+
errors,
|
|
37
|
+
}: ListDialogProps<T>) {
|
|
38
|
+
const { items, loading, error } = useAsyncList(load)
|
|
39
|
+
const itemRows: DialogRow[] = items.map(item => {
|
|
40
|
+
const button: DialogItem = { type: 'button', label: labelOf(item), right: rightOf?.(item), onPress: () => onSelect(item) }
|
|
41
|
+
return { items: [button] }
|
|
42
|
+
})
|
|
43
|
+
const footer: DialogFooterLine[] = loading ? [loadingLine()] : footerLines ?? []
|
|
44
|
+
const errorLines: DialogFooterLine[] = [
|
|
45
|
+
...(error !== null ? [errorLine(error)] : []),
|
|
46
|
+
...(errors ?? []),
|
|
47
|
+
]
|
|
48
|
+
return (
|
|
49
|
+
<Dialog
|
|
50
|
+
ref={ref}
|
|
51
|
+
width={width}
|
|
52
|
+
maxHeight={maxHeight}
|
|
53
|
+
title={title}
|
|
54
|
+
rows={itemRows}
|
|
55
|
+
footer={footer}
|
|
56
|
+
errors={errorLines}
|
|
57
|
+
onClose={onClose}
|
|
58
|
+
search={search}
|
|
59
|
+
searchRight={searchRight}
|
|
60
|
+
centerScroll
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { Ref } from 'react'
|
|
3
|
+
import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import type { ConfiguredModel, CustomProviderForm, DescribedModel, ModelEntryConfig, OfficialProvider } from '../../chat/models.ts'
|
|
5
|
+
import { PI_AI_SETTINGS_NS } from '../../chat/models.ts'
|
|
6
|
+
import { COLORS } from '../../theme.ts'
|
|
7
|
+
import { glyphs } from '../../terminal/glyphs.ts'
|
|
8
|
+
import { errorLine, loadingLine } from './status-lines.ts'
|
|
9
|
+
import { useAsyncAction } from '../hooks/use-async-action.ts'
|
|
10
|
+
import { useAsyncList } from '../hooks/use-async-list.ts'
|
|
11
|
+
import { Dialog } from './dialog.tsx'
|
|
12
|
+
import type { DialogFooterLine, DialogHandle, DialogItem, DialogRow } from './dialog.tsx'
|
|
13
|
+
import { DIALOG_MAX_HEIGHT, DIALOG_WIDTH_MEDIUM } from './sizes.ts'
|
|
14
|
+
|
|
15
|
+
export interface ModelApi {
|
|
16
|
+
listModels(): Promise<ConfiguredModel[]>
|
|
17
|
+
selectModel(provider: string, model: string): Promise<void>
|
|
18
|
+
fetchCustomModels(form: CustomProviderForm): Promise<LlmDiscoveredModel[]>
|
|
19
|
+
saveCustomProvider(form: CustomProviderForm, models: LlmDiscoveredModel[]): Promise<void>
|
|
20
|
+
listProviderDirectory(): Promise<OfficialProvider[]>
|
|
21
|
+
fetchProviderModels(provider: OfficialProvider, apiKey: string): Promise<LlmDiscoveredModel[] | undefined>
|
|
22
|
+
saveProviderKey(provider: OfficialProvider, apiKey: string): Promise<void>
|
|
23
|
+
saveProviderModels(provider: OfficialProvider, models: LlmDiscoveredModel[]): Promise<void>
|
|
24
|
+
deleteProvider(provider: OfficialProvider): Promise<void>
|
|
25
|
+
readModelEntries(ns: string, provider: string): ModelEntryConfig[]
|
|
26
|
+
describeModel(provider: string, model: string): Promise<DescribedModel>
|
|
27
|
+
saveModelEntry(ns: string, provider: string, entry: ModelEntryConfig): Promise<void>
|
|
28
|
+
deleteModelEntry(ns: string, provider: string, modelId: string): Promise<void>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ModelsDialogProps {
|
|
32
|
+
api: ModelApi
|
|
33
|
+
onClose: () => void
|
|
34
|
+
onModelSelected: (provider: string, model: string) => void
|
|
35
|
+
onAddProvider: () => void
|
|
36
|
+
ref?: Ref<DialogHandle>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Window =
|
|
40
|
+
| { kind: 'list' }
|
|
41
|
+
| { kind: 'configure-model' }
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
export function ModelsDialog({ api, onClose, onModelSelected, onAddProvider, ref }: ModelsDialogProps) {
|
|
45
|
+
const [window, setWindow] = useState<Window>({ kind: 'list' })
|
|
46
|
+
const { error, clearError, run } = useAsyncAction()
|
|
47
|
+
const { items: models, loading, error: loadError, reload } = useAsyncList(api.listModels)
|
|
48
|
+
const modelItemRefs = useRef(new Map<DialogItem, ConfiguredModel>())
|
|
49
|
+
const modelItemMap = new Map<DialogItem, ConfiguredModel>()
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
modelItemRefs.current = modelItemMap
|
|
52
|
+
})
|
|
53
|
+
const [configTarget, setConfigTarget] = useState<ConfiguredModel | null>(null)
|
|
54
|
+
const [configDraft, setConfigDraft] = useState<{ name: string; contextWindow: string; image: string }>({
|
|
55
|
+
name: '',
|
|
56
|
+
contextWindow: '',
|
|
57
|
+
image: '',
|
|
58
|
+
})
|
|
59
|
+
const configureRequestRef = useRef(0)
|
|
60
|
+
const selectModel = async (model: ConfiguredModel) => {
|
|
61
|
+
await run(async () => {
|
|
62
|
+
await api.selectModel(model.provider, model.id)
|
|
63
|
+
onModelSelected(model.provider, model.id)
|
|
64
|
+
onClose()
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
const openConfigure = (model: ConfiguredModel) => {
|
|
68
|
+
const settings = api.readModelEntries(PI_AI_SETTINGS_NS, model.provider).find(entry => entry.id === model.id)
|
|
69
|
+
setConfigTarget(model)
|
|
70
|
+
setConfigDraft({
|
|
71
|
+
name: settings?.name ?? '',
|
|
72
|
+
contextWindow: settings?.contextWindow === undefined ? '' : String(settings.contextWindow),
|
|
73
|
+
image: settings?.input === undefined ? '' : String(settings.input.includes('image')),
|
|
74
|
+
})
|
|
75
|
+
clearError()
|
|
76
|
+
setWindow({ kind: 'configure-model' })
|
|
77
|
+
const request = ++configureRequestRef.current
|
|
78
|
+
void api.describeModel(model.provider, model.id).then(info => {
|
|
79
|
+
if (configureRequestRef.current !== request) return
|
|
80
|
+
setConfigDraft(current => ({
|
|
81
|
+
name: current.name === '' ? info.name : current.name,
|
|
82
|
+
contextWindow: current.contextWindow === '' && info.contextWindow !== undefined ? String(info.contextWindow) : current.contextWindow,
|
|
83
|
+
image: current.image === '' ? String(info.image) : current.image,
|
|
84
|
+
}))
|
|
85
|
+
}).catch(() => {})
|
|
86
|
+
}
|
|
87
|
+
const submitConfigure = async () => {
|
|
88
|
+
if (configTarget === null) return
|
|
89
|
+
await run(async () => {
|
|
90
|
+
const name = configDraft.name.trim()
|
|
91
|
+
const contextWindow = configDraft.contextWindow.trim() === '' ? undefined : Number(configDraft.contextWindow)
|
|
92
|
+
if (contextWindow !== undefined && (!Number.isInteger(contextWindow) || contextWindow <= 0)) {
|
|
93
|
+
throw new Error('Context window must be a positive integer')
|
|
94
|
+
}
|
|
95
|
+
const image = configDraft.image.trim().toLowerCase()
|
|
96
|
+
let input: string[] | undefined
|
|
97
|
+
if (image !== '') {
|
|
98
|
+
if (image !== 'true' && image !== 'false') throw new Error('imageModality must be true or false')
|
|
99
|
+
input = image === 'true' ? ['text', 'image'] : ['text']
|
|
100
|
+
}
|
|
101
|
+
const existing = api.readModelEntries(PI_AI_SETTINGS_NS, configTarget.provider).find(entry => entry.id === configTarget.id)
|
|
102
|
+
const entry: ModelEntryConfig = { ...existing, id: configTarget.id }
|
|
103
|
+
if (name === '') delete entry.name
|
|
104
|
+
else entry.name = name
|
|
105
|
+
if (contextWindow === undefined) delete entry.contextWindow
|
|
106
|
+
else entry.contextWindow = contextWindow
|
|
107
|
+
if (input === undefined) delete entry.input
|
|
108
|
+
else entry.input = input
|
|
109
|
+
await api.saveModelEntry(PI_AI_SETTINGS_NS, configTarget.provider, entry)
|
|
110
|
+
setConfigTarget(null)
|
|
111
|
+
clearError()
|
|
112
|
+
setWindow({ kind: 'list' })
|
|
113
|
+
reload()
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
const handleCtrlE = (model: ConfiguredModel): boolean => {
|
|
117
|
+
openConfigure(model)
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
const configureRows: DialogRow[] = configTarget === null ? [] : [
|
|
121
|
+
{
|
|
122
|
+
items: [
|
|
123
|
+
{ type: 'input', label: 'name', value: configDraft.name, onChange: value => setConfigDraft(current => ({ ...current, name: value })) },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
items: [
|
|
128
|
+
{ type: 'input', label: 'contextWindow', value: configDraft.contextWindow, onChange: value => setConfigDraft(current => ({ ...current, contextWindow: value })) },
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
items: [
|
|
133
|
+
{ type: 'input', label: 'imageModality', value: configDraft.image, onChange: value => setConfigDraft(current => ({ ...current, image: value })) },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
items: [
|
|
138
|
+
{ type: 'actions', confirmLabel: 'Submit', cancelLabel: 'Cancel', onConfirm: () => void submitConfigure(), onCancel: () => setWindow({ kind: 'list' }) },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
const footerLines: DialogFooterLine[] = error !== null ? [errorLine(error)] : []
|
|
143
|
+
if (window.kind === 'configure-model') {
|
|
144
|
+
return (
|
|
145
|
+
<Dialog
|
|
146
|
+
ref={ref}
|
|
147
|
+
key="configure-model"
|
|
148
|
+
width={DIALOG_WIDTH_MEDIUM}
|
|
149
|
+
maxHeight={DIALOG_MAX_HEIGHT}
|
|
150
|
+
title="Configure Model"
|
|
151
|
+
rows={configureRows}
|
|
152
|
+
errors={footerLines}
|
|
153
|
+
onClose={onClose}
|
|
154
|
+
/>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
const listRows: DialogRow[] = models.map(model => {
|
|
158
|
+
const item: DialogItem = {
|
|
159
|
+
type: 'button',
|
|
160
|
+
label: model.name,
|
|
161
|
+
right: model.providerName,
|
|
162
|
+
onPress: () => void selectModel(model),
|
|
163
|
+
}
|
|
164
|
+
modelItemMap.set(item, model)
|
|
165
|
+
return { items: [item] }
|
|
166
|
+
})
|
|
167
|
+
const listFooter: DialogFooterLine[] = [
|
|
168
|
+
{
|
|
169
|
+
text: `Ctrl+A add providers ${glyphs.separator} Ctrl+E configure`,
|
|
170
|
+
color: COLORS.dialogHintText,
|
|
171
|
+
},
|
|
172
|
+
...(loading ? [loadingLine()] : []),
|
|
173
|
+
]
|
|
174
|
+
const listErrors: DialogFooterLine[] = [
|
|
175
|
+
...(loadError !== null ? [errorLine(loadError)] : []),
|
|
176
|
+
...footerLines,
|
|
177
|
+
]
|
|
178
|
+
const handleCtrlEItem = (focused: DialogItem | undefined): boolean => {
|
|
179
|
+
const model = focused === undefined ? undefined : modelItemRefs.current.get(focused)
|
|
180
|
+
return model === undefined ? false : handleCtrlE(model)
|
|
181
|
+
}
|
|
182
|
+
return (
|
|
183
|
+
<Dialog
|
|
184
|
+
key="list"
|
|
185
|
+
ref={ref}
|
|
186
|
+
width={DIALOG_WIDTH_MEDIUM}
|
|
187
|
+
maxHeight={DIALOG_MAX_HEIGHT}
|
|
188
|
+
title="models"
|
|
189
|
+
rows={listRows}
|
|
190
|
+
footer={listFooter}
|
|
191
|
+
errors={listErrors}
|
|
192
|
+
onClose={onClose}
|
|
193
|
+
search
|
|
194
|
+
searchRight
|
|
195
|
+
centerScroll
|
|
196
|
+
onCtrlA={(_focused) => {
|
|
197
|
+
onAddProvider()
|
|
198
|
+
return true
|
|
199
|
+
}}
|
|
200
|
+
onCtrlE={handleCtrlEItem}
|
|
201
|
+
/>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Ref } from 'react'
|
|
2
|
+
import { errorLine } from './status-lines.ts'
|
|
3
|
+
import type { PresetSummary } from '../../chat/presets.ts'
|
|
4
|
+
import { useAsyncAction } from '../hooks/use-async-action.ts'
|
|
5
|
+
import { ListDialog } from './list-dialog.tsx'
|
|
6
|
+
import { DIALOG_WIDTH_MEDIUM, DIALOG_MAX_HEIGHT } from './sizes.ts'
|
|
7
|
+
import type { DialogHandle, DialogFooterLine } from './dialog.tsx'
|
|
8
|
+
|
|
9
|
+
export interface PresetsApi {
|
|
10
|
+
listPresets(): Promise<PresetSummary[]>
|
|
11
|
+
currentPreset(): string
|
|
12
|
+
selectPreset(id: string): Promise<void>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PresetsDialogProps {
|
|
16
|
+
api: PresetsApi
|
|
17
|
+
onClose: () => void
|
|
18
|
+
ref?: Ref<DialogHandle>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
export function PresetsDialog({ api, onClose, ref }: PresetsDialogProps) {
|
|
23
|
+
const { error, run } = useAsyncAction()
|
|
24
|
+
const current = api.currentPreset()
|
|
25
|
+
const selectPreset = async (id: string) => {
|
|
26
|
+
const result = await run(() => api.selectPreset(id))
|
|
27
|
+
if (!result.ok) return
|
|
28
|
+
onClose()
|
|
29
|
+
}
|
|
30
|
+
const footerLines: DialogFooterLine[] = error === null ? [] : [errorLine(error)]
|
|
31
|
+
return (
|
|
32
|
+
<ListDialog
|
|
33
|
+
ref={ref}
|
|
34
|
+
title="preset"
|
|
35
|
+
width={DIALOG_WIDTH_MEDIUM}
|
|
36
|
+
maxHeight={DIALOG_MAX_HEIGHT}
|
|
37
|
+
load={api.listPresets}
|
|
38
|
+
search
|
|
39
|
+
labelOf={preset => preset.name}
|
|
40
|
+
rightOf={preset => (preset.id === current ? 'current' : undefined)}
|
|
41
|
+
onSelect={preset => void selectPreset(preset.id)}
|
|
42
|
+
onClose={onClose}
|
|
43
|
+
errors={footerLines}
|
|
44
|
+
/>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { Ref } from 'react'
|
|
3
|
+
import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import type { CustomProviderForm, OfficialProvider } from '../../chat/models.ts'
|
|
5
|
+
import { API_PROTOCOLS, isProviderIdValid } from '../../chat/models.ts'
|
|
6
|
+
import { COLORS } from '../../theme.ts'
|
|
7
|
+
import { errorLine, loadingLine } from './status-lines.ts'
|
|
8
|
+
import { useAsyncAction } from '../hooks/use-async-action.ts'
|
|
9
|
+
import { useAsyncList } from '../hooks/use-async-list.ts'
|
|
10
|
+
import { Dialog } from './dialog.tsx'
|
|
11
|
+
import type { DialogFooterLine, DialogHandle, DialogItem, DialogRow } from './dialog.tsx'
|
|
12
|
+
import { DIALOG_MAX_HEIGHT, DIALOG_WIDTH_MEDIUM } from './sizes.ts'
|
|
13
|
+
import type { ModelApi } from './models-dialog.tsx'
|
|
14
|
+
|
|
15
|
+
export interface ProvidersDialogProps {
|
|
16
|
+
api: ModelApi
|
|
17
|
+
onClose: () => void
|
|
18
|
+
onModelSelected: (provider: string, model: string) => void
|
|
19
|
+
ref?: Ref<DialogHandle>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type Window =
|
|
23
|
+
| { kind: 'providers' }
|
|
24
|
+
| { kind: 'add-provider-key' }
|
|
25
|
+
| { kind: 'add-custom' }
|
|
26
|
+
| { kind: 'select-models' }
|
|
27
|
+
|
|
28
|
+
const ARM_TIMEOUT_MS = 3000
|
|
29
|
+
|
|
30
|
+
const DELETE_HINT: DialogFooterLine[] = [
|
|
31
|
+
{ text: 'Ctrl+D to delete the custom provider', color: COLORS.dialogHintText },
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const EMPTY_FORM: CustomProviderForm = {
|
|
36
|
+
providerId: '',
|
|
37
|
+
displayName: '',
|
|
38
|
+
apiUrl: '',
|
|
39
|
+
apiProtocol: API_PROTOCOLS[0],
|
|
40
|
+
apiKey: '',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sourceOf(entry: OfficialProvider): string {
|
|
44
|
+
return entry.declared === true ? 'custom' : entry.settingsNs
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ProvidersDialog({ api, onClose, onModelSelected, ref }: ProvidersDialogProps) {
|
|
48
|
+
const [window, setWindow] = useState<Window>({ kind: 'providers' })
|
|
49
|
+
const { error, setError, clearError, run } = useAsyncAction()
|
|
50
|
+
const [form, setForm] = useState<CustomProviderForm>(EMPTY_FORM)
|
|
51
|
+
const [provider, setProvider] = useState<OfficialProvider | null>(null)
|
|
52
|
+
const [providerKey, setProviderKey] = useState('')
|
|
53
|
+
const [discovered, setDiscovered] = useState<LlmDiscoveredModel[]>([])
|
|
54
|
+
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
|
|
55
|
+
const [fetching, setFetching] = useState(false)
|
|
56
|
+
const fetchingRef = useRef(false)
|
|
57
|
+
const [armedKey, setArmedKey] = useState<string | null>(null)
|
|
58
|
+
const armedRef = useRef<string | null>(null)
|
|
59
|
+
const armTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
60
|
+
const providerItemRefs = useRef(new Map<DialogItem, OfficialProvider>())
|
|
61
|
+
const providerItemMap = new Map<DialogItem, OfficialProvider>()
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
providerItemRefs.current = providerItemMap
|
|
64
|
+
})
|
|
65
|
+
const { items: directory, loading, error: loadError, reload } = useAsyncList(api.listProviderDirectory)
|
|
66
|
+
const disarm = () => {
|
|
67
|
+
clearTimeout(armTimer.current)
|
|
68
|
+
if (armedRef.current !== null) {
|
|
69
|
+
armedRef.current = null
|
|
70
|
+
setArmedKey(null)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
useEffect(() => () => clearTimeout(armTimer.current), [])
|
|
74
|
+
const deleteProvider = async (entry: OfficialProvider) => {
|
|
75
|
+
const result = await run(() => api.deleteProvider(entry))
|
|
76
|
+
if (!result.ok) return
|
|
77
|
+
clearError()
|
|
78
|
+
reload()
|
|
79
|
+
}
|
|
80
|
+
const handleCtrlD = (focused: DialogItem | undefined): boolean => {
|
|
81
|
+
if (focused?.type !== 'button') return false
|
|
82
|
+
const entry = providerItemRefs.current.get(focused)
|
|
83
|
+
if (entry === undefined || entry.declared !== true) return false
|
|
84
|
+
if (armedRef.current === entry.provider) {
|
|
85
|
+
disarm()
|
|
86
|
+
void deleteProvider(entry)
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
clearTimeout(armTimer.current)
|
|
90
|
+
armedRef.current = entry.provider
|
|
91
|
+
setArmedKey(entry.provider)
|
|
92
|
+
armTimer.current = setTimeout(disarm, ARM_TIMEOUT_MS)
|
|
93
|
+
return true
|
|
94
|
+
}
|
|
95
|
+
const openCustom = () => {
|
|
96
|
+
clearError()
|
|
97
|
+
setWindow({ kind: 'add-custom' })
|
|
98
|
+
}
|
|
99
|
+
const selectDirectoryProvider = (entry: OfficialProvider) => {
|
|
100
|
+
clearError()
|
|
101
|
+
setProvider(entry)
|
|
102
|
+
setProviderKey('')
|
|
103
|
+
setWindow({ kind: 'add-provider-key' })
|
|
104
|
+
}
|
|
105
|
+
const resetProvider = () => {
|
|
106
|
+
setProvider(null)
|
|
107
|
+
setProviderKey('')
|
|
108
|
+
}
|
|
109
|
+
const setField = <K extends keyof CustomProviderForm>(key: K, value: CustomProviderForm[K]) => {
|
|
110
|
+
setForm(current => ({ ...current, [key]: value }))
|
|
111
|
+
}
|
|
112
|
+
const fetchIntoSelection = async (fetchModels: () => Promise<LlmDiscoveredModel[]>) => {
|
|
113
|
+
const found = await fetchModels()
|
|
114
|
+
if (found.length === 0) throw new Error('Failed to fetch models')
|
|
115
|
+
setDiscovered(found)
|
|
116
|
+
setPicked(new Set())
|
|
117
|
+
clearError()
|
|
118
|
+
setWindow({ kind: 'select-models' })
|
|
119
|
+
}
|
|
120
|
+
const submitCustom = async () => {
|
|
121
|
+
if (fetchingRef.current) return
|
|
122
|
+
clearError()
|
|
123
|
+
if (!isProviderIdValid(form.providerId.trim())) {
|
|
124
|
+
setError('Provider ID must start with a letter and use only letters, digits, _ and -')
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
fetchingRef.current = true
|
|
128
|
+
setFetching(true)
|
|
129
|
+
await run(() => fetchIntoSelection(() => api.fetchCustomModels({ ...form, providerId: form.providerId.trim() })))
|
|
130
|
+
fetchingRef.current = false
|
|
131
|
+
setFetching(false)
|
|
132
|
+
}
|
|
133
|
+
const submitProviderKey = async () => {
|
|
134
|
+
if (provider === null || fetchingRef.current) return
|
|
135
|
+
fetchingRef.current = true
|
|
136
|
+
setFetching(true)
|
|
137
|
+
await run(async () => {
|
|
138
|
+
if (providerKey.trim() !== '') await api.saveProviderKey(provider, providerKey)
|
|
139
|
+
const found = await api.fetchProviderModels(provider, providerKey)
|
|
140
|
+
if (found === undefined) {
|
|
141
|
+
resetProvider()
|
|
142
|
+
clearError()
|
|
143
|
+
setWindow({ kind: 'providers' })
|
|
144
|
+
reload()
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (found.length === 0) throw new Error('Failed to fetch models')
|
|
148
|
+
setDiscovered(found)
|
|
149
|
+
setPicked(new Set())
|
|
150
|
+
clearError()
|
|
151
|
+
setWindow({ kind: 'select-models' })
|
|
152
|
+
})
|
|
153
|
+
fetchingRef.current = false
|
|
154
|
+
setFetching(false)
|
|
155
|
+
}
|
|
156
|
+
const toggleModel = (id: string) => {
|
|
157
|
+
setPicked(current => {
|
|
158
|
+
const next = new Set(current)
|
|
159
|
+
if (next.has(id)) next.delete(id)
|
|
160
|
+
else next.add(id)
|
|
161
|
+
return next
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
const submitModels = async () => {
|
|
165
|
+
await run(async () => {
|
|
166
|
+
if (picked.size === 0) throw new Error('Select at least one model')
|
|
167
|
+
const chosen = discovered.filter(model => picked.has(model.id))
|
|
168
|
+
if (provider !== null) {
|
|
169
|
+
await api.saveProviderModels(provider, chosen)
|
|
170
|
+
resetProvider()
|
|
171
|
+
} else {
|
|
172
|
+
await api.saveCustomProvider(form, chosen)
|
|
173
|
+
setForm(EMPTY_FORM)
|
|
174
|
+
}
|
|
175
|
+
clearError()
|
|
176
|
+
setWindow({ kind: 'providers' })
|
|
177
|
+
reload()
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
const providerKeyRows: DialogRow[] = [
|
|
181
|
+
{
|
|
182
|
+
items: [
|
|
183
|
+
{ type: 'input', label: 'API Key', value: providerKey, onChange: setProviderKey },
|
|
184
|
+
],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
items: [
|
|
188
|
+
{ type: 'actions', confirmLabel: 'Submit', cancelLabel: 'Cancel', onConfirm: () => void submitProviderKey(), onCancel: onClose },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
]
|
|
192
|
+
const customRows: DialogRow[] = [
|
|
193
|
+
{ items: [{ type: 'input', label: 'Provider ID', value: form.providerId, onChange: value => setField('providerId', value) }] },
|
|
194
|
+
{ items: [{ type: 'input', label: 'Display Name', value: form.displayName, onChange: value => setField('displayName', value) }] },
|
|
195
|
+
{ items: [{ type: 'input', label: 'API URL', value: form.apiUrl, onChange: value => setField('apiUrl', value) }] },
|
|
196
|
+
{
|
|
197
|
+
items: [
|
|
198
|
+
{
|
|
199
|
+
type: 'input',
|
|
200
|
+
label: 'API Key',
|
|
201
|
+
value: form.apiKey,
|
|
202
|
+
onChange: value => setField('apiKey', value),
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
items: [
|
|
208
|
+
{
|
|
209
|
+
type: 'select',
|
|
210
|
+
label: 'API Protocol',
|
|
211
|
+
value: form.apiProtocol,
|
|
212
|
+
options: API_PROTOCOLS,
|
|
213
|
+
onChange: value => setField('apiProtocol', value),
|
|
214
|
+
spaced: true,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
items: [
|
|
220
|
+
{ type: 'actions', confirmLabel: 'Submit', cancelLabel: 'Cancel', onConfirm: () => void submitCustom(), onCancel: onClose },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
]
|
|
224
|
+
const selectRows: DialogRow[] = discovered.map(model => ({
|
|
225
|
+
items: [
|
|
226
|
+
{
|
|
227
|
+
type: 'checkbox',
|
|
228
|
+
label: model.name ?? model.id,
|
|
229
|
+
checked: picked.has(model.id),
|
|
230
|
+
onToggle: () => toggleModel(model.id),
|
|
231
|
+
onConfirm: () => void submitModels(),
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
}))
|
|
235
|
+
const statusLines: DialogFooterLine[] = [
|
|
236
|
+
...(window.kind === 'select-models' ? [{ text: 'Press Space to toggle, Enter to confirm', color: COLORS.dialogHintText }] : []),
|
|
237
|
+
...(fetching && (window.kind === 'add-provider-key' || window.kind === 'add-custom') ? [{ text: 'Fetching models...' }] : []),
|
|
238
|
+
]
|
|
239
|
+
const errorLines: DialogFooterLine[] = error !== null ? [errorLine(error)] : []
|
|
240
|
+
if (window.kind !== 'providers') {
|
|
241
|
+
const title =
|
|
242
|
+
window.kind === 'add-provider-key'
|
|
243
|
+
? `Add ${provider?.displayName ?? provider?.provider ?? ''}`
|
|
244
|
+
: window.kind === 'add-custom'
|
|
245
|
+
? 'Add Custom Provider'
|
|
246
|
+
: 'Select Models'
|
|
247
|
+
const rows = window.kind === 'add-provider-key'
|
|
248
|
+
? providerKeyRows
|
|
249
|
+
: window.kind === 'add-custom'
|
|
250
|
+
? customRows
|
|
251
|
+
: selectRows
|
|
252
|
+
return (
|
|
253
|
+
<Dialog
|
|
254
|
+
ref={ref}
|
|
255
|
+
key={window.kind}
|
|
256
|
+
width={DIALOG_WIDTH_MEDIUM}
|
|
257
|
+
maxHeight={DIALOG_MAX_HEIGHT}
|
|
258
|
+
title={title}
|
|
259
|
+
rows={rows}
|
|
260
|
+
footer={statusLines}
|
|
261
|
+
errors={errorLines}
|
|
262
|
+
onClose={onClose}
|
|
263
|
+
search={window.kind === 'select-models'}
|
|
264
|
+
centerScroll={window.kind === 'select-models'}
|
|
265
|
+
/>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
const providerRows: DialogRow[] = [
|
|
269
|
+
...directory.map(entry => {
|
|
270
|
+
const armed = armedKey === entry.provider
|
|
271
|
+
const item: DialogItem = {
|
|
272
|
+
type: 'button',
|
|
273
|
+
label: entry.displayName || entry.provider,
|
|
274
|
+
right: armed ? 'Press Ctrl+D again to delete' : sourceOf(entry),
|
|
275
|
+
onPress: () => selectDirectoryProvider(entry),
|
|
276
|
+
...(armed ? { rightColor: COLORS.errorText } : {}),
|
|
277
|
+
}
|
|
278
|
+
providerItemMap.set(item, entry)
|
|
279
|
+
return { items: [item] }
|
|
280
|
+
}),
|
|
281
|
+
{
|
|
282
|
+
items: [
|
|
283
|
+
{ type: 'button', label: 'Custom Provider', right: 'add manually', onPress: openCustom },
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
]
|
|
287
|
+
const providerFooter: DialogFooterLine[] = [
|
|
288
|
+
...(loading ? [loadingLine()] : []),
|
|
289
|
+
...DELETE_HINT,
|
|
290
|
+
...statusLines,
|
|
291
|
+
]
|
|
292
|
+
const providerErrors: DialogFooterLine[] = [
|
|
293
|
+
...(loadError !== null ? [errorLine(loadError)] : []),
|
|
294
|
+
...errorLines,
|
|
295
|
+
]
|
|
296
|
+
return (
|
|
297
|
+
<Dialog
|
|
298
|
+
key="providers"
|
|
299
|
+
ref={ref}
|
|
300
|
+
width={DIALOG_WIDTH_MEDIUM}
|
|
301
|
+
maxHeight={DIALOG_MAX_HEIGHT}
|
|
302
|
+
title="providers"
|
|
303
|
+
rows={providerRows}
|
|
304
|
+
footer={providerFooter}
|
|
305
|
+
errors={providerErrors}
|
|
306
|
+
onClose={onClose}
|
|
307
|
+
search
|
|
308
|
+
centerScroll
|
|
309
|
+
onCtrlD={handleCtrlD}
|
|
310
|
+
onActivity={disarm}
|
|
311
|
+
/>
|
|
312
|
+
)
|
|
313
|
+
}
|