@mohou/panel 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/lib/types/chrome/hue.d.ts +3 -0
- package/lib/types/chrome/hue.d.ts.map +1 -0
- package/lib/types/gallery/card-style.d.ts +8 -0
- package/lib/types/gallery/card-style.d.ts.map +1 -0
- package/lib/types/gallery/client.d.ts +20 -0
- package/lib/types/gallery/client.d.ts.map +1 -0
- package/lib/types/gallery/desk.d.ts +16 -0
- package/lib/types/gallery/desk.d.ts.map +1 -0
- package/lib/types/gallery/list.d.ts +37 -0
- package/lib/types/gallery/list.d.ts.map +1 -0
- package/lib/types/gallery/sort.d.ts +24 -0
- package/lib/types/gallery/sort.d.ts.map +1 -0
- package/lib/types/gallery/state.d.ts +90 -0
- package/lib/types/gallery/state.d.ts.map +1 -0
- package/lib/types/gallery/tabs.d.ts +45 -0
- package/lib/types/gallery/tabs.d.ts.map +1 -0
- package/lib/types/gallery/view.d.ts +57 -0
- package/lib/types/gallery/view.d.ts.map +1 -0
- package/lib/types/history/client.d.ts +25 -0
- package/lib/types/history/client.d.ts.map +1 -0
- package/lib/types/history/state.d.ts +39 -0
- package/lib/types/history/state.d.ts.map +1 -0
- package/lib/types/history/view.d.ts +15 -0
- package/lib/types/history/view.d.ts.map +1 -0
- package/lib/types/index.d.ts +24 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/labels.d.ts +267 -0
- package/lib/types/labels.d.ts.map +1 -0
- package/lib/types/settings/author-mcp-view.d.ts +15 -0
- package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
- package/lib/types/settings/author-mcp.d.ts +4 -0
- package/lib/types/settings/author-mcp.d.ts.map +1 -0
- package/lib/types/settings/client.d.ts +198 -0
- package/lib/types/settings/client.d.ts.map +1 -0
- package/lib/types/settings/credentials-view.d.ts +15 -0
- package/lib/types/settings/credentials-view.d.ts.map +1 -0
- package/lib/types/settings/form.d.ts +27 -0
- package/lib/types/settings/form.d.ts.map +1 -0
- package/lib/types/settings/install-row.d.ts +10 -0
- package/lib/types/settings/install-row.d.ts.map +1 -0
- package/lib/types/settings/mcp-view.d.ts +14 -0
- package/lib/types/settings/mcp-view.d.ts.map +1 -0
- package/lib/types/settings/skill-dirs.d.ts +6 -0
- package/lib/types/settings/skill-dirs.d.ts.map +1 -0
- package/lib/types/settings/skill-install.d.ts +13 -0
- package/lib/types/settings/skill-install.d.ts.map +1 -0
- package/lib/types/settings/state.d.ts +94 -0
- package/lib/types/settings/state.d.ts.map +1 -0
- package/lib/types/settings/update-dialog.d.ts +56 -0
- package/lib/types/settings/update-dialog.d.ts.map +1 -0
- package/lib/types/settings/view.d.ts +26 -0
- package/lib/types/settings/view.d.ts.map +1 -0
- package/lib/types/storage/client.d.ts +15 -0
- package/lib/types/storage/client.d.ts.map +1 -0
- package/lib/types/storage/state.d.ts +42 -0
- package/lib/types/storage/state.d.ts.map +1 -0
- package/lib/types/storage/view.d.ts +16 -0
- package/lib/types/storage/view.d.ts.map +1 -0
- package/lib/types/surface/state.d.ts +42 -0
- package/lib/types/surface/state.d.ts.map +1 -0
- package/lib/types/surface/view.d.ts +34 -0
- package/lib/types/surface/view.d.ts.map +1 -0
- package/lib/types/theme/client.d.ts +45 -0
- package/lib/types/theme/client.d.ts.map +1 -0
- package/lib/types/theme/menu.d.ts +25 -0
- package/lib/types/theme/menu.d.ts.map +1 -0
- package/lib/types/theme/state.d.ts +46 -0
- package/lib/types/theme/state.d.ts.map +1 -0
- package/lib/types/theme/view.d.ts +16 -0
- package/lib/types/theme/view.d.ts.map +1 -0
- package/lib/types/ui/dialog.d.ts +12 -0
- package/lib/types/ui/dialog.d.ts.map +1 -0
- package/lib/types/ui/tooltip.d.ts +12 -0
- package/lib/types/ui/tooltip.d.ts.map +1 -0
- package/package.json +38 -0
- package/src/chrome/hue.ts +6 -0
- package/src/gallery/card-style.ts +27 -0
- package/src/gallery/client.ts +26 -0
- package/src/gallery/desk.tsx +54 -0
- package/src/gallery/list.ts +54 -0
- package/src/gallery/sort.ts +72 -0
- package/src/gallery/state.ts +229 -0
- package/src/gallery/tabs.ts +85 -0
- package/src/gallery/view.tsx +501 -0
- package/src/history/client.ts +27 -0
- package/src/history/state.ts +90 -0
- package/src/history/view.tsx +131 -0
- package/src/index.ts +61 -0
- package/src/labels.ts +536 -0
- package/src/settings/author-mcp-view.tsx +154 -0
- package/src/settings/author-mcp.ts +24 -0
- package/src/settings/client.ts +205 -0
- package/src/settings/credentials-view.tsx +195 -0
- package/src/settings/form.ts +27 -0
- package/src/settings/install-row.tsx +35 -0
- package/src/settings/mcp-view.tsx +793 -0
- package/src/settings/skill-dirs.ts +41 -0
- package/src/settings/skill-install.tsx +134 -0
- package/src/settings/state.ts +225 -0
- package/src/settings/update-dialog.tsx +207 -0
- package/src/settings/view.tsx +722 -0
- package/src/storage/client.ts +16 -0
- package/src/storage/state.ts +94 -0
- package/src/storage/view.tsx +190 -0
- package/src/surface/state.ts +72 -0
- package/src/surface/view.tsx +255 -0
- package/src/theme/client.ts +34 -0
- package/src/theme/menu.tsx +286 -0
- package/src/theme/state.ts +113 -0
- package/src/theme/view.tsx +73 -0
- package/src/ui/dialog.tsx +25 -0
- package/src/ui/tooltip.tsx +19 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { Check, ExternalLink, Loader2, Pencil, RefreshCw } from 'lucide-react'
|
|
2
|
+
import { useEffect, useReducer, useState, type ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import { galleryCardStyles, type GalleryCardStyle } from '../gallery/list.ts'
|
|
5
|
+
import { isPanelLocale, panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
|
|
6
|
+
import { Dialog } from '../ui/dialog.tsx'
|
|
7
|
+
import type { PaletteChip } from '../theme/client.ts'
|
|
8
|
+
import { applyDocumentMode, paintHostStyle } from '../theme/menu.tsx'
|
|
9
|
+
import { AuthorMcpInstall } from './author-mcp-view.tsx'
|
|
10
|
+
import type { PanelAbout, PanelAuthorMcpStatus, PanelPolicy, PanelRuntime, PanelSettingsClient, PanelSkillStatus, PanelUpdateCheck } from './client.ts'
|
|
11
|
+
import { CredentialSettings } from './credentials-view.tsx'
|
|
12
|
+
import { McpSettings } from './mcp-view.tsx'
|
|
13
|
+
import { SkillInstall } from './skill-install.tsx'
|
|
14
|
+
import { loadSettings, reduceSettings, settingsDraft, settingsState } from './state.ts'
|
|
15
|
+
|
|
16
|
+
/** The rail. Credentials appear only when Host exposes the read call, so a store-less host shows no entry. */
|
|
17
|
+
function sections(props: { readonly client?: PanelSettingsClient }): readonly SettingsSection[] {
|
|
18
|
+
return props.client?.readCredentials === undefined
|
|
19
|
+
? ['appearance', 'network', 'agent', 'mcp', 'about']
|
|
20
|
+
: ['appearance', 'network', 'agent', 'mcp', 'credentials', 'about']
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type SettingsSection = 'appearance' | 'network' | 'agent' | 'mcp' | 'credentials' | 'about'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Settings form. Hidden when Host exposes no config client. No route string lives here.
|
|
27
|
+
* @param props - client, chrome locale, and the palette list for the appearance cards
|
|
28
|
+
*/
|
|
29
|
+
export function PanelSettings(props: {
|
|
30
|
+
readonly client?: PanelSettingsClient
|
|
31
|
+
readonly locale: PanelLocale
|
|
32
|
+
readonly mode: PanelLabelMode
|
|
33
|
+
readonly versions?: string
|
|
34
|
+
readonly palettes?: readonly PaletteChip[]
|
|
35
|
+
readonly cardStyle?: GalleryCardStyle
|
|
36
|
+
readonly onCardStyle?: (style: GalleryCardStyle) => void
|
|
37
|
+
readonly onClose?: () => void
|
|
38
|
+
readonly onPolicy?: (policy: PanelPolicy) => void
|
|
39
|
+
/** Live host policy from the theme menu (or other writers). */
|
|
40
|
+
readonly hostPolicy?: PanelPolicy
|
|
41
|
+
/** Preview chrome locale while the form is open (discard restores the saved locale). */
|
|
42
|
+
readonly onPreviewLocale?: (locale: PanelLocale) => void
|
|
43
|
+
readonly onUpdateOffer?: (update: PanelUpdateCheck) => void
|
|
44
|
+
}): ReactNode {
|
|
45
|
+
const [state, dispatch] = useReducer(reduceSettings, undefined, settingsState)
|
|
46
|
+
const [saveFeedback, setSaveFeedback] = useState<'idle' | 'saving' | 'saved'>('idle')
|
|
47
|
+
const [section, setSection] = useState<SettingsSection>('appearance')
|
|
48
|
+
const [runtimes, setRuntimes] = useState<readonly PanelRuntime[]>([])
|
|
49
|
+
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
|
50
|
+
const [update, setUpdate] = useState<PanelUpdateCheck | 'checking' | 'failed' | undefined>(undefined)
|
|
51
|
+
const [restarting, setRestarting] = useState(false)
|
|
52
|
+
const [portUnlocked, setPortUnlocked] = useState(false)
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (section !== 'network') setPortUnlocked(false)
|
|
55
|
+
}, [section])
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (props.client === undefined) return
|
|
58
|
+
void loadSettings(props.client, dispatch)
|
|
59
|
+
}, [props.client])
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (props.hostPolicy === undefined) return
|
|
62
|
+
dispatch({ type: 'host-policy', policy: props.hostPolicy })
|
|
63
|
+
}, [props.hostPolicy])
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (state.closed) props.onClose?.()
|
|
66
|
+
}, [state.closed])
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const list = props.client?.listRuntimes
|
|
69
|
+
if (list === undefined) return
|
|
70
|
+
void list().then(setRuntimes, () => setRuntimes([]))
|
|
71
|
+
}, [props.client])
|
|
72
|
+
// Live preview: theme / palette / locale. Discard paths restore via fromPolicy then this effect.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (state.saved === undefined) return
|
|
75
|
+
if (state.theme === 'light' || state.theme === 'dark' || state.theme === 'system') {
|
|
76
|
+
applyDocumentMode(state.theme)
|
|
77
|
+
}
|
|
78
|
+
const chip = (props.palettes ?? []).find(item => item.id === state.palette)
|
|
79
|
+
if (chip?.style !== undefined && chip.style.length > 0) paintHostStyle(chip.style)
|
|
80
|
+
if (isPanelLocale(state.locale)) props.onPreviewLocale?.(state.locale)
|
|
81
|
+
}, [state.theme, state.palette, state.locale, state.saved, props.palettes, props.onPreviewLocale])
|
|
82
|
+
if (props.client === undefined) return null
|
|
83
|
+
const labelLocale = isPanelLocale(state.locale) ? state.locale : props.locale
|
|
84
|
+
const label = (key: string) => panelText(labelLocale, key, props.mode)
|
|
85
|
+
const client = props.client
|
|
86
|
+
const onSave = async () => {
|
|
87
|
+
if (saveFeedback === 'saving') return
|
|
88
|
+
setSaveFeedback('saving')
|
|
89
|
+
const started = Date.now()
|
|
90
|
+
const written = await saveSettings(client, state, dispatch)
|
|
91
|
+
// Keep the in-progress spinner visible long enough to read (fast saves otherwise flash).
|
|
92
|
+
const elapsed = Date.now() - started
|
|
93
|
+
if (elapsed < 500) await new Promise(resolve => window.setTimeout(resolve, 500 - elapsed))
|
|
94
|
+
if (written === undefined) {
|
|
95
|
+
setSaveFeedback('idle')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
props.onPolicy?.(written)
|
|
99
|
+
setSaveFeedback('saved')
|
|
100
|
+
window.setTimeout(() => setSaveFeedback('idle'), 2000)
|
|
101
|
+
}
|
|
102
|
+
return (
|
|
103
|
+
<form
|
|
104
|
+
className="flex h-full flex-col bg-background text-foreground"
|
|
105
|
+
data-dirty={state.dirty}
|
|
106
|
+
data-closed={state.closed}
|
|
107
|
+
onKeyDown={(event) => {
|
|
108
|
+
if (event.key === 'Escape') dispatch({ type: 'escape' })
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<header className="flex items-center justify-between border-b bg-card px-4 py-3">
|
|
112
|
+
<div className="min-w-0">
|
|
113
|
+
<h3>{label('settings')}</h3>
|
|
114
|
+
</div>
|
|
115
|
+
<div className="flex items-center gap-2">
|
|
116
|
+
<button type="button" className="inline-flex h-8 items-center rounded-lg px-3 text-sm hover:bg-muted" onClick={() => dispatch({ type: 'escape' })}>{label('settings-close')}</button>
|
|
117
|
+
</div>
|
|
118
|
+
</header>
|
|
119
|
+
<div className="flex min-h-0 flex-1">
|
|
120
|
+
<nav className="flex w-36 shrink-0 flex-col gap-1 border-r bg-card p-3">
|
|
121
|
+
{sections(props).map(id => (
|
|
122
|
+
<button key={id} type="button" className="rounded-lg px-2 py-1.5 text-left text-sm data-[on=1]:bg-muted data-[on=1]:font-medium" data-nav={id} data-on={section === id ? '1' : '0'} onClick={() => setSection(id)}>{label(`section-${id}`)}</button>
|
|
123
|
+
))}
|
|
124
|
+
</nav>
|
|
125
|
+
<div className="min-h-0 flex-1 overflow-auto px-6 py-5">
|
|
126
|
+
{section === 'mcp' ? <McpSettings client={client} locale={labelLocale} mode={props.mode} /> : null}
|
|
127
|
+
{section === 'credentials' ? <CredentialSettings client={client} locale={labelLocale} mode={props.mode} /> : null}
|
|
128
|
+
{section === 'appearance' ? (<>
|
|
129
|
+
<section className="mb-6">
|
|
130
|
+
<h4>{label('section-appearance')}</h4>
|
|
131
|
+
<div className="mb-4 flex flex-col gap-2">
|
|
132
|
+
<span className="text-sm text-muted-foreground">{label('language')}</span>
|
|
133
|
+
<div className="flex max-w-xs gap-1 rounded-lg bg-muted p-1">
|
|
134
|
+
<button type="button" className="h-7 flex-1 rounded-md text-xs data-[on=1]:bg-card data-[on=1]:font-semibold data-[on=1]:shadow-sm" data-on={state.locale === 'zh-CN' ? '1' : '0'} onClick={() => dispatch({ type: 'edit-locale', locale: 'zh-CN' })}>{label('lang-zh')}</button>
|
|
135
|
+
<button type="button" className="h-7 flex-1 rounded-md text-xs data-[on=1]:bg-card data-[on=1]:font-semibold data-[on=1]:shadow-sm" data-on={state.locale === 'en' ? '1' : '0'} onClick={() => dispatch({ type: 'edit-locale', locale: 'en' })}>{label('lang-en')}</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
<div className="mb-4 flex flex-col gap-2">
|
|
139
|
+
<span className="text-sm text-muted-foreground">{label('appearance')}</span>
|
|
140
|
+
<div className="grid grid-cols-3 gap-2">
|
|
141
|
+
{(['system', 'light', 'dark'] as const).map(theme => (
|
|
142
|
+
<button key={theme} type="button" className={choiceCardClass} data-on={state.theme === theme ? '1' : '0'} onClick={() => dispatch({ type: 'edit-theme', theme })}>
|
|
143
|
+
<span className={theme === 'dark' ? 'block h-10 rounded-lg bg-foreground/80' : theme === 'light' ? 'block h-10 rounded-lg border bg-background' : 'block h-10 rounded-lg bg-gradient-to-r from-background to-foreground/80'} />
|
|
144
|
+
<span className="text-xs data-[on=1]:font-semibold">{label(themeLabel(theme))}</span>
|
|
145
|
+
</button>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="mb-4 flex flex-col gap-2">
|
|
150
|
+
<span className="text-sm text-muted-foreground">{label('palette')}</span>
|
|
151
|
+
<input className="sr-only" value={state.palette} onChange={event => dispatch({ type: 'edit-palette', palette: event.target.value })} />
|
|
152
|
+
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
153
|
+
{(props.palettes ?? []).map((palette) => {
|
|
154
|
+
const chip = palettePreview(palette)
|
|
155
|
+
return (
|
|
156
|
+
<button key={palette.id} type="button" className={choiceCardClass} data-on={state.palette === palette.id ? '1' : '0'} onClick={() => dispatch({ type: 'edit-palette', palette: palette.id })}>
|
|
157
|
+
<span className="flex h-10 items-end gap-1 rounded-lg border border-border/60 px-2 pb-2" style={{ background: chip.surface }}>
|
|
158
|
+
<span className="h-5 flex-1 rounded-sm" style={{ background: chip.primary }} />
|
|
159
|
+
<span className="h-3 flex-1 rounded-sm" style={{ background: chip.muted }} />
|
|
160
|
+
<span className="h-4 w-3 rounded-sm border border-border/50" style={{ background: chip.card }} />
|
|
161
|
+
</span>
|
|
162
|
+
<span className="text-xs">{paletteLabel(labelLocale, palette)}</span>
|
|
163
|
+
</button>
|
|
164
|
+
)
|
|
165
|
+
})}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
{props.onCardStyle === undefined || props.cardStyle === undefined ? null : (
|
|
169
|
+
<div className="mb-4 flex flex-col gap-2">
|
|
170
|
+
<span className="text-sm text-muted-foreground">{label('card-style')}</span>
|
|
171
|
+
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
172
|
+
{galleryCardStyles.map(style => (
|
|
173
|
+
<button key={style} type="button" className={choiceCardClass} aria-pressed={props.cardStyle === style} data-on={props.cardStyle === style ? '1' : '0'} onClick={() => props.onCardStyle?.(style)}>
|
|
174
|
+
<CardStylePreview style={style} />
|
|
175
|
+
<span className="text-xs">{label(cardKey(style))}</span>
|
|
176
|
+
</button>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</section>
|
|
182
|
+
</>) : null}
|
|
183
|
+
{section === 'network' ? (
|
|
184
|
+
<section className="mb-6">
|
|
185
|
+
<h4>{label('section-network')}</h4>
|
|
186
|
+
<div className={state.portError ? 'mb-4 flex flex-col gap-2 text-destructive' : 'mb-4 flex flex-col gap-2'}>
|
|
187
|
+
<div className="flex items-center gap-2">
|
|
188
|
+
<label className="text-sm text-muted-foreground" htmlFor="settings-host-port">{label('port')}</label>
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
192
|
+
aria-label={portUnlocked ? label('port-lock') : label('port-edit')}
|
|
193
|
+
aria-pressed={portUnlocked}
|
|
194
|
+
onClick={() => { setPortUnlocked(open => !open) }}
|
|
195
|
+
>
|
|
196
|
+
<Pencil size={14} className={portUnlocked ? 'text-primary' : 'opacity-60'} />
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
<p className="m-0 max-w-xl text-xs leading-5 text-muted-foreground">{label('port-help')}</p>
|
|
200
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
201
|
+
<div className="flex items-center gap-0">
|
|
202
|
+
<span className="flex h-9 items-center rounded-l-lg border bg-muted px-2 font-mono text-sm text-muted-foreground">127.0.0.1</span>
|
|
203
|
+
<span className="flex h-9 items-center border border-l-0 bg-muted px-1 font-mono text-sm text-muted-foreground">:</span>
|
|
204
|
+
<input
|
|
205
|
+
id="settings-host-port"
|
|
206
|
+
data-field="port"
|
|
207
|
+
readOnly={!portUnlocked}
|
|
208
|
+
className={portUnlocked
|
|
209
|
+
? 'h-9 w-28 rounded-r-lg border border-l-0 bg-card px-3 font-mono text-sm outline-none'
|
|
210
|
+
: 'h-9 w-28 cursor-default rounded-r-lg border border-l-0 bg-muted px-3 font-mono text-sm text-muted-foreground outline-none'}
|
|
211
|
+
value={state.port}
|
|
212
|
+
onChange={(event) => {
|
|
213
|
+
if (!portUnlocked) return
|
|
214
|
+
dispatch({ type: 'edit-port', port: event.target.value })
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
</div>
|
|
218
|
+
{hostOrigin(state.port) === undefined ? null : (
|
|
219
|
+
<a
|
|
220
|
+
className="inline-flex h-9 items-center gap-1.5 rounded-lg border px-3 text-sm text-primary hover:bg-muted"
|
|
221
|
+
href={hostOrigin(state.port)}
|
|
222
|
+
target="_blank"
|
|
223
|
+
rel="noreferrer"
|
|
224
|
+
>
|
|
225
|
+
<ExternalLink size={14} />
|
|
226
|
+
{label('port-open-panel')}
|
|
227
|
+
</a>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
{hostOrigin(state.port) === undefined ? null : (
|
|
231
|
+
<div className="mt-1 grid max-w-xl gap-1 text-xs text-muted-foreground">
|
|
232
|
+
<p className="m-0 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
233
|
+
<span>{label('port-panel-url')}</span>
|
|
234
|
+
<a className="font-mono text-foreground underline-offset-2 hover:underline" href={hostOrigin(state.port)} target="_blank" rel="noreferrer">{hostOrigin(state.port)}</a>
|
|
235
|
+
</p>
|
|
236
|
+
<p className="m-0 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
|
237
|
+
<span>{label('port-mcp-url')}</span>
|
|
238
|
+
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground">{`${hostOrigin(state.port)}/mcp`}</code>
|
|
239
|
+
</p>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
{state.portError ? <p className="text-xs text-muted-foreground">{label('port-invalid')}</p> : null}
|
|
243
|
+
</div>
|
|
244
|
+
</section>
|
|
245
|
+
) : null}
|
|
246
|
+
{section === 'agent' ? (
|
|
247
|
+
<AgentSection
|
|
248
|
+
state={state}
|
|
249
|
+
runtimes={runtimes}
|
|
250
|
+
busy={runtimeBusy}
|
|
251
|
+
restarting={restarting}
|
|
252
|
+
label={label}
|
|
253
|
+
onRuntime={(providerId) => {
|
|
254
|
+
dispatch({ type: 'edit-provider', providerId })
|
|
255
|
+
const first = runtimes.find(item => item.id === providerId)?.models[0]
|
|
256
|
+
dispatch({ type: 'edit-model', provider: first?.provider ?? '', model: first?.models[0] ?? '' })
|
|
257
|
+
}}
|
|
258
|
+
onModel={(provider, model) => dispatch({ type: 'edit-model', provider, model })}
|
|
259
|
+
onProbe={() => { void probeSettings(client, state.providerId, dispatch) }}
|
|
260
|
+
onRefresh={() => {
|
|
261
|
+
const list = client.listRuntimes
|
|
262
|
+
if (list === undefined) return
|
|
263
|
+
setRuntimeBusy(true)
|
|
264
|
+
void list().then(setRuntimes, () => setRuntimes([])).finally(() => setRuntimeBusy(false))
|
|
265
|
+
}}
|
|
266
|
+
{...client.restartHost === undefined ? {} : {
|
|
267
|
+
onRestart: () => {
|
|
268
|
+
void rebootHost(client, setRestarting, label, dispatch, hostOrigin(String(state.saved?.hostPort ?? state.port)))
|
|
269
|
+
},
|
|
270
|
+
}}
|
|
271
|
+
{...client.readAbout === undefined ? {} : { readAbout: client.readAbout }}
|
|
272
|
+
{...client.readSkill === undefined ? {} : { readSkill: client.readSkill }}
|
|
273
|
+
{...client.installSkill === undefined ? {} : { installSkill: client.installSkill }}
|
|
274
|
+
{...client.revealSkill === undefined ? {} : { revealSkill: client.revealSkill }}
|
|
275
|
+
{...client.readAuthorMcp === undefined ? {} : { readAuthorMcp: client.readAuthorMcp }}
|
|
276
|
+
{...client.installAuthorMcp === undefined ? {} : { installAuthorMcp: client.installAuthorMcp }}
|
|
277
|
+
{...client.revealAuthorMcp === undefined ? {} : { revealAuthorMcp: client.revealAuthorMcp }}
|
|
278
|
+
/>
|
|
279
|
+
) : null}
|
|
280
|
+
{section === 'about' ? (
|
|
281
|
+
<AboutSection
|
|
282
|
+
update={update}
|
|
283
|
+
label={label}
|
|
284
|
+
{...props.versions === undefined ? {} : { versions: props.versions }}
|
|
285
|
+
{...client.checkUpdate === undefined ? {} : { onCheck: () => { void readUpdate(client, setUpdate, props.onUpdateOffer) } }}
|
|
286
|
+
/>
|
|
287
|
+
) : null}
|
|
288
|
+
{section === 'mcp' || section === 'about' ? null : state.saveError ? <p className="text-sm text-destructive">{state.saveMessage !== undefined && state.saveMessage.length > 0 ? state.saveMessage : label('save-failed')}</p> : null}
|
|
289
|
+
{section === 'mcp' || section === 'about' || section === 'agent' || !state.restartRequired ? null : (
|
|
290
|
+
<div className="mt-3 space-y-2">
|
|
291
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
292
|
+
<p className="m-0 text-sm text-primary">{label('restart-required')}</p>
|
|
293
|
+
{client.restartHost === undefined ? null : (
|
|
294
|
+
<button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground disabled:opacity-60" disabled={restarting} onClick={() => { void rebootHost(client, setRestarting, label, dispatch, hostOrigin(String(state.saved?.hostPort ?? state.port))) }}>{restarting ? label('restart-wait') : label('restart-host')}</button>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
{state.restartPort ? <p className="m-0 text-sm text-muted-foreground">{label('restart-mcp-hint')}</p> : null}
|
|
298
|
+
{state.restartRuntime ? <p className="m-0 text-sm text-muted-foreground">{label('restart-runtime-hint')}</p> : null}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
{state.pending === undefined ? null : (
|
|
302
|
+
<Dialog width="sm" onClose={() => dispatch({ type: 'escape' })}>
|
|
303
|
+
<p>{label('unsaved-confirm')}</p>
|
|
304
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
305
|
+
<button type="button" onClick={() => dispatch({ type: 'escape' })}>{label('cancel')}</button>
|
|
306
|
+
<button type="button" className="go" onClick={() => dispatch({ type: 'confirm' })}>{label('confirm')}</button>
|
|
307
|
+
</div>
|
|
308
|
+
</Dialog>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
{section === 'mcp' || section === 'about' ? null : <footer className="flex items-center justify-between border-t bg-card px-4 py-3">
|
|
313
|
+
<div className="flex gap-2">
|
|
314
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm hover:bg-muted" onClick={() => dispatch({ type: 'restore' })}>{label('restore')}</button>
|
|
315
|
+
</div>
|
|
316
|
+
<div className="flex items-center gap-2">
|
|
317
|
+
<span
|
|
318
|
+
className={[
|
|
319
|
+
'inline-flex min-w-[4.5rem] items-center justify-end gap-1.5 text-xs font-medium transition-all duration-300 ease-out',
|
|
320
|
+
saveFeedback === 'idle' ? 'pointer-events-none translate-x-1 opacity-0' : 'translate-x-0 opacity-100',
|
|
321
|
+
saveFeedback === 'saving' ? 'text-muted-foreground' : '',
|
|
322
|
+
saveFeedback === 'saved' ? 'text-emerald-600 dark:text-emerald-400' : '',
|
|
323
|
+
].filter(Boolean).join(' ')}
|
|
324
|
+
aria-live="polite"
|
|
325
|
+
>
|
|
326
|
+
{saveFeedback === 'saving' ? <Loader2 size={14} className="shrink-0 animate-spin" aria-hidden /> : null}
|
|
327
|
+
{saveFeedback === 'saved' ? <Check size={14} className="shrink-0 animate-in zoom-in-95 fade-in-0 duration-300" aria-hidden /> : null}
|
|
328
|
+
{saveFeedback === 'saving' ? label('saving') : null}
|
|
329
|
+
{saveFeedback === 'saved' ? label('saved') : null}
|
|
330
|
+
</span>
|
|
331
|
+
<button
|
|
332
|
+
type="button"
|
|
333
|
+
className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground disabled:opacity-60"
|
|
334
|
+
disabled={saveFeedback === 'saving'}
|
|
335
|
+
onClick={() => { void onSave() }}
|
|
336
|
+
>
|
|
337
|
+
{saveFeedback === 'saving' ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
|
338
|
+
{saveFeedback === 'saving' ? label('saving') : label('save')}
|
|
339
|
+
</button>
|
|
340
|
+
</div>
|
|
341
|
+
</footer>}
|
|
342
|
+
</form>
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function AgentSection(props: {
|
|
347
|
+
readonly state: ReturnType<typeof settingsState>
|
|
348
|
+
readonly runtimes: readonly PanelRuntime[]
|
|
349
|
+
readonly busy: boolean
|
|
350
|
+
readonly restarting: boolean
|
|
351
|
+
readonly label: (key: string) => string
|
|
352
|
+
readonly onRuntime: (id: string) => void
|
|
353
|
+
readonly onModel: (provider: string, model: string) => void
|
|
354
|
+
readonly onProbe: () => void
|
|
355
|
+
readonly onRefresh: () => void
|
|
356
|
+
readonly onRestart?: () => void
|
|
357
|
+
readonly readAbout?: () => Promise<PanelAbout>
|
|
358
|
+
readonly readSkill?: (customDirs?: readonly string[]) => Promise<PanelSkillStatus>
|
|
359
|
+
readonly installSkill?: (agentIds: readonly string[], customDirs: readonly string[]) => Promise<PanelSkillStatus>
|
|
360
|
+
readonly revealSkill?: (dest: string) => Promise<void>
|
|
361
|
+
readonly readAuthorMcp?: () => Promise<PanelAuthorMcpStatus>
|
|
362
|
+
readonly installAuthorMcp?: (agentIds: readonly string[], description: string) => Promise<PanelAuthorMcpStatus>
|
|
363
|
+
readonly revealAuthorMcp?: (dest: string) => Promise<void>
|
|
364
|
+
}): ReactNode {
|
|
365
|
+
const runtimes = props.runtimes.some(item => item.id === props.state.providerId) || props.state.providerId.length === 0
|
|
366
|
+
? props.runtimes
|
|
367
|
+
: [...props.runtimes, { id: props.state.providerId, models: [] }]
|
|
368
|
+
const runtime = runtimes.find(item => item.id === props.state.providerId) ?? runtimes[0]
|
|
369
|
+
const vendors = runtime?.models ?? []
|
|
370
|
+
const vendor = vendors.find(item => item.provider === props.state.modelProvider) ?? vendors[0]
|
|
371
|
+
const models = vendor?.models ?? []
|
|
372
|
+
const vendorId = vendor?.provider ?? ''
|
|
373
|
+
const modelId = models.includes(props.state.model) ? props.state.model : (models[0] ?? '')
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
if (vendorId.length === 0 || modelId.length === 0) return
|
|
376
|
+
if (vendorId === props.state.modelProvider && modelId === props.state.model) return
|
|
377
|
+
props.onModel(vendorId, modelId)
|
|
378
|
+
}, [vendorId, modelId, props.state.modelProvider, props.state.model, props.onModel])
|
|
379
|
+
return (
|
|
380
|
+
<div className="flex flex-col gap-8">
|
|
381
|
+
<section>
|
|
382
|
+
<h4 className="m-0">{props.label('section-model')}</h4>
|
|
383
|
+
<p className="mt-1 mb-4 text-sm text-muted-foreground">{props.label('agent-apps-help')}</p>
|
|
384
|
+
{runtimes.length === 0 ? <p className="text-sm text-muted-foreground">{props.label('runtime-missing')}</p> : (
|
|
385
|
+
<>
|
|
386
|
+
<div className="mb-4 flex items-center gap-3">
|
|
387
|
+
<div className="flex w-full max-w-md shrink-0 items-center gap-2">
|
|
388
|
+
<select className="h-9 min-w-0 flex-1 rounded-lg border bg-card px-3 text-sm outline-none" value={props.state.providerId} onChange={event => props.onRuntime(event.target.value)}>
|
|
389
|
+
{runtimes.map(item => <option key={item.id} value={item.id}>{item.label ?? item.id}</option>)}
|
|
390
|
+
</select>
|
|
391
|
+
<button type="button" className="inline-flex size-9 items-center justify-center rounded-lg border hover:bg-muted" aria-label={props.label('refresh')} onClick={props.onRefresh}>
|
|
392
|
+
<RefreshCw size={14} className={props.busy ? 'animate-spin' : ''} />
|
|
393
|
+
</button>
|
|
394
|
+
<button type="button" className="h-9 shrink-0 rounded-lg border px-3 text-sm hover:bg-muted" onClick={props.onProbe}>{props.label('probe')}</button>
|
|
395
|
+
</div>
|
|
396
|
+
{props.state.probe === undefined ? null : <span className="min-w-0 text-sm leading-5 text-muted-foreground">{probeText(props.state.probe, props.label)}</span>}
|
|
397
|
+
</div>
|
|
398
|
+
{runtimeRestartNote(props)}
|
|
399
|
+
</>
|
|
400
|
+
)}
|
|
401
|
+
{vendors.length === 0 ? <p className="text-sm text-muted-foreground">{props.label('model-empty')}</p> : (
|
|
402
|
+
<div className="grid max-w-md gap-3 min-[720px]:grid-cols-2">
|
|
403
|
+
<label className="flex flex-col gap-2">
|
|
404
|
+
<span className="text-sm text-muted-foreground">{props.label('provider')}</span>
|
|
405
|
+
<select className="h-9 rounded-lg border bg-card px-3 text-sm outline-none" value={vendorId} onChange={(event) => {
|
|
406
|
+
const provider = event.target.value
|
|
407
|
+
const next = vendors.find(item => item.provider === provider)?.models ?? []
|
|
408
|
+
props.onModel(provider, next.includes(props.state.model) ? props.state.model : (next[0] ?? ''))
|
|
409
|
+
}}>
|
|
410
|
+
{vendors.map(item => <option key={item.provider} value={item.provider}>{item.provider}</option>)}
|
|
411
|
+
</select>
|
|
412
|
+
</label>
|
|
413
|
+
<label className="flex flex-col gap-2">
|
|
414
|
+
<span className="text-sm text-muted-foreground">{props.label('model')}</span>
|
|
415
|
+
<select className="h-9 rounded-lg border bg-card px-3 text-sm outline-none" value={modelId} onChange={event => props.onModel(vendorId, event.target.value)}>
|
|
416
|
+
{models.map(name => <option key={name} value={name}>{name}</option>)}
|
|
417
|
+
</select>
|
|
418
|
+
</label>
|
|
419
|
+
</div>
|
|
420
|
+
)}
|
|
421
|
+
</section>
|
|
422
|
+
<section>
|
|
423
|
+
<h4 className="m-0">{props.label('agent-others')}</h4>
|
|
424
|
+
<p className="mt-1 mb-4 text-sm text-muted-foreground">{props.label('agent-others-help')}</p>
|
|
425
|
+
<div className="flex flex-col gap-3">
|
|
426
|
+
<AuthorMcpInstall
|
|
427
|
+
label={props.label}
|
|
428
|
+
{...props.readAbout === undefined ? {} : { readAbout: props.readAbout }}
|
|
429
|
+
{...props.readAuthorMcp === undefined ? {} : { readAuthorMcp: props.readAuthorMcp }}
|
|
430
|
+
{...props.installAuthorMcp === undefined ? {} : { installAuthorMcp: props.installAuthorMcp }}
|
|
431
|
+
{...props.revealAuthorMcp === undefined ? {} : { revealAuthorMcp: props.revealAuthorMcp }}
|
|
432
|
+
/>
|
|
433
|
+
<SkillInstall
|
|
434
|
+
label={props.label}
|
|
435
|
+
{...props.readSkill === undefined ? {} : { readSkill: props.readSkill }}
|
|
436
|
+
{...props.installSkill === undefined ? {} : { installSkill: props.installSkill }}
|
|
437
|
+
{...props.revealSkill === undefined ? {} : { revealSkill: props.revealSkill }}
|
|
438
|
+
/>
|
|
439
|
+
</div>
|
|
440
|
+
</section>
|
|
441
|
+
</div>
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function AboutSection(props: {
|
|
446
|
+
readonly versions?: string
|
|
447
|
+
readonly update: PanelUpdateCheck | 'checking' | 'failed' | undefined
|
|
448
|
+
readonly label: (key: string) => string
|
|
449
|
+
readonly onCheck?: () => void
|
|
450
|
+
}): ReactNode {
|
|
451
|
+
const lines = (props.versions ?? '').split('\n').filter(line => line.length > 0)
|
|
452
|
+
return (
|
|
453
|
+
<section className="mb-6">
|
|
454
|
+
<h4>{props.label('section-about')}</h4>
|
|
455
|
+
<h3 className="mt-2 text-base font-semibold">{props.label('product-name')}</h3>
|
|
456
|
+
<div className="mt-2 mb-4 max-w-2xl space-y-2 text-sm leading-6 text-muted-foreground">
|
|
457
|
+
{props.label('product-meaning').split('\n').filter(line => line.length > 0).map(line => (
|
|
458
|
+
<p key={line.slice(0, 24)} className="m-0">{line}</p>
|
|
459
|
+
))}
|
|
460
|
+
</div>
|
|
461
|
+
{lines.length === 0 ? null : (
|
|
462
|
+
<ul className="mb-4 font-mono text-sm text-muted-foreground">
|
|
463
|
+
{lines.map(line => <li key={line}>{line}</li>)}
|
|
464
|
+
</ul>
|
|
465
|
+
)}
|
|
466
|
+
{props.onCheck === undefined ? null : (
|
|
467
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm hover:bg-muted" onClick={props.onCheck}>{props.label('update')}</button>
|
|
468
|
+
)}
|
|
469
|
+
{props.update === undefined ? null : <p className="mt-3 text-sm">{updateText(props.update, props.label)}</p>}
|
|
470
|
+
</section>
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function themeLabel(theme: 'system' | 'light' | 'dark'): 'theme-system' | 'theme-light' | 'theme-dark' {
|
|
475
|
+
if (theme === 'light') return 'theme-light'
|
|
476
|
+
if (theme === 'dark') return 'theme-dark'
|
|
477
|
+
return 'theme-system'
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function CardStylePreview(props: { readonly style: GalleryCardStyle }): ReactNode {
|
|
481
|
+
if (props.style === 'stamp') {
|
|
482
|
+
return (
|
|
483
|
+
<span className="relative block h-10 overflow-hidden rounded-lg border bg-card">
|
|
484
|
+
<span className="absolute top-1.5 right-1.5 flex size-5 items-center justify-center rounded border text-[8px] font-extrabold">Ab</span>
|
|
485
|
+
<span className="absolute bottom-2 left-2 h-1 w-8 rounded-full bg-foreground/70" />
|
|
486
|
+
</span>
|
|
487
|
+
)
|
|
488
|
+
}
|
|
489
|
+
if (props.style === 'etch') {
|
|
490
|
+
return (
|
|
491
|
+
<span className="flex h-10 items-end rounded-lg border bg-card px-2 pb-1.5">
|
|
492
|
+
<svg viewBox="0 0 52 28" className="h-7 w-12" aria-hidden><text x="1" y="22" fill="var(--card)" stroke="currentColor" strokeWidth="3.2" paintOrder="stroke fill" fontSize="20" fontWeight="800" fontFamily="ui-sans-serif, system-ui, sans-serif">Ab</text></svg>
|
|
493
|
+
</span>
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
if (props.style === 'pulse') {
|
|
497
|
+
return (
|
|
498
|
+
<span className="relative flex h-10 items-center rounded-[14px] border bg-card px-2 shadow-[inset_1px_1px_2px_#fff,0_6px_8px_#00000014]">
|
|
499
|
+
<span className="text-sm font-extrabold text-primary">Ab</span>
|
|
500
|
+
<span className="absolute top-2 right-2 size-2 rounded-full bg-primary shadow-[0_0_8px_var(--primary)]" />
|
|
501
|
+
</span>
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
if (props.style === 'list') {
|
|
505
|
+
return (
|
|
506
|
+
<span className="relative block h-10 overflow-hidden rounded-lg border bg-card">
|
|
507
|
+
<span className="absolute inset-1 rounded-md rounded-tr-[18px] bg-foreground/10" />
|
|
508
|
+
<span className="absolute inset-y-1.5 left-1.5 w-7 rounded-md bg-card/70" />
|
|
509
|
+
</span>
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
if (props.style === 'glass') {
|
|
513
|
+
return (
|
|
514
|
+
<span className="relative block h-10 overflow-hidden rounded-lg border bg-card/60">
|
|
515
|
+
<span className="absolute top-1.5 left-2 text-sm font-extrabold tracking-tight text-foreground/80">Ab</span>
|
|
516
|
+
<span className="absolute right-2 bottom-1.5 h-1 w-8 rounded-full bg-foreground/15" />
|
|
517
|
+
</span>
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
return (
|
|
521
|
+
<span className="relative block h-10 overflow-hidden rounded-lg border bg-card">
|
|
522
|
+
<span className="absolute -top-3 -right-2 size-10 rounded-full bg-primary/25" />
|
|
523
|
+
<span className="absolute bottom-1.5 left-2 text-sm leading-none font-extrabold text-primary">Ab</span>
|
|
524
|
+
</span>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function cardKey(style: GalleryCardStyle): 'card-hero' | 'card-stamp' | 'card-etch' | 'card-pulse' | 'card-list' | 'card-glass' {
|
|
529
|
+
if (style === 'stamp') return 'card-stamp'
|
|
530
|
+
if (style === 'etch') return 'card-etch'
|
|
531
|
+
if (style === 'pulse') return 'card-pulse'
|
|
532
|
+
if (style === 'list') return 'card-list'
|
|
533
|
+
if (style === 'glass') return 'card-glass'
|
|
534
|
+
return 'card-hero'
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function saveSettings(
|
|
538
|
+
client: PanelSettingsClient,
|
|
539
|
+
state: ReturnType<typeof settingsState>,
|
|
540
|
+
dispatch: (action: Parameters<typeof reduceSettings>[1]) => void,
|
|
541
|
+
): Promise<PanelPolicy | undefined> {
|
|
542
|
+
const draft = settingsDraft(state)
|
|
543
|
+
if (!draft.ok) {
|
|
544
|
+
dispatch({ type: 'port-invalid' })
|
|
545
|
+
return undefined
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const result = await client.writePolicy(draft.policy)
|
|
549
|
+
dispatch({ type: 'saved', result })
|
|
550
|
+
return result.policy
|
|
551
|
+
} catch (error) {
|
|
552
|
+
const message = error instanceof Error ? error.message : ''
|
|
553
|
+
dispatch({ type: 'save-failed', ...message.length > 0 ? { message } : {} })
|
|
554
|
+
return undefined
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function runtimeRestartNote(props: {
|
|
559
|
+
readonly state: ReturnType<typeof settingsState>
|
|
560
|
+
readonly restarting: boolean
|
|
561
|
+
readonly label: (key: string) => string
|
|
562
|
+
readonly onRestart?: () => void
|
|
563
|
+
}): ReactNode {
|
|
564
|
+
const savedId = props.state.saved?.runtimeProvider.id
|
|
565
|
+
const dirtyRuntime = props.state.dirty
|
|
566
|
+
&& savedId !== undefined
|
|
567
|
+
&& savedId !== props.state.providerId
|
|
568
|
+
if (props.state.restartRequired && props.state.restartRuntime) {
|
|
569
|
+
return (
|
|
570
|
+
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2">
|
|
571
|
+
<p className="m-0 min-w-0 flex-1 text-sm text-primary">{props.label('restart-runtime-hint')}</p>
|
|
572
|
+
{props.onRestart === undefined ? null : (
|
|
573
|
+
<button
|
|
574
|
+
type="button"
|
|
575
|
+
className="h-8 shrink-0 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground disabled:opacity-60"
|
|
576
|
+
disabled={props.restarting}
|
|
577
|
+
onClick={props.onRestart}
|
|
578
|
+
>
|
|
579
|
+
{props.restarting ? props.label('restart-wait') : props.label('restart-host')}
|
|
580
|
+
</button>
|
|
581
|
+
)}
|
|
582
|
+
</div>
|
|
583
|
+
)
|
|
584
|
+
}
|
|
585
|
+
if (dirtyRuntime) {
|
|
586
|
+
return <p className="m-0 text-sm text-muted-foreground">{props.label('restart-runtime-pending')}</p>
|
|
587
|
+
}
|
|
588
|
+
return null
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function rebootHost(
|
|
592
|
+
client: PanelSettingsClient,
|
|
593
|
+
setRestarting: (value: boolean) => void,
|
|
594
|
+
label: (key: string) => string,
|
|
595
|
+
dispatch: (action: Parameters<typeof reduceSettings>[1]) => void,
|
|
596
|
+
nextOrigin?: string,
|
|
597
|
+
): Promise<void> {
|
|
598
|
+
if (client.restartHost === undefined) return
|
|
599
|
+
setRestarting(true)
|
|
600
|
+
const target = nextOrigin !== undefined && nextOrigin.length > 0 ? nextOrigin : window.location.origin
|
|
601
|
+
// Kick restart; the socket may die before the JSON body arrives — that is OK.
|
|
602
|
+
void client.restartHost().catch(() => undefined)
|
|
603
|
+
const ready = await waitForHostOrigin(target, 20_000)
|
|
604
|
+
if (ready) {
|
|
605
|
+
if (target !== window.location.origin) window.location.assign(`${target}/`)
|
|
606
|
+
else window.location.reload()
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
setRestarting(false)
|
|
610
|
+
dispatch({ type: 'save-failed', message: label('restart-failed') })
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Poll until the host answers again after dispose+start.
|
|
615
|
+
* Prefer seeing a downtime gap so a probe before dispose does not look "ready".
|
|
616
|
+
*/
|
|
617
|
+
async function waitForHostOrigin(origin: string, budgetMs: number): Promise<boolean> {
|
|
618
|
+
const started = Date.now()
|
|
619
|
+
let sawDown = false
|
|
620
|
+
// Outlast the server's delayed dispose (400ms) before trusting a 200.
|
|
621
|
+
await new Promise(resolve => setTimeout(resolve, 700))
|
|
622
|
+
while (Date.now() - started < budgetMs) {
|
|
623
|
+
try {
|
|
624
|
+
const response = await fetch(`${origin}/api/about`, {
|
|
625
|
+
method: 'GET',
|
|
626
|
+
cache: 'no-store',
|
|
627
|
+
signal: AbortSignal.timeout(1500),
|
|
628
|
+
})
|
|
629
|
+
if (response.ok) {
|
|
630
|
+
if (sawDown || Date.now() - started > 1_500) return true
|
|
631
|
+
} else {
|
|
632
|
+
sawDown = true
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
sawDown = true
|
|
636
|
+
}
|
|
637
|
+
await new Promise(resolve => setTimeout(resolve, 350))
|
|
638
|
+
}
|
|
639
|
+
return false
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function readUpdate(
|
|
643
|
+
client: PanelSettingsClient,
|
|
644
|
+
setUpdate: (value: PanelUpdateCheck | 'checking' | 'failed') => void,
|
|
645
|
+
onOffer?: (update: PanelUpdateCheck) => void,
|
|
646
|
+
): Promise<void> {
|
|
647
|
+
if (client.checkUpdate === undefined) return
|
|
648
|
+
setUpdate('checking')
|
|
649
|
+
try {
|
|
650
|
+
const result = await client.checkUpdate()
|
|
651
|
+
setUpdate(result)
|
|
652
|
+
if (result.updateAvailable && result.installable === true && result.latest !== null) onOffer?.(result)
|
|
653
|
+
} catch {
|
|
654
|
+
setUpdate('failed')
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function updateText(update: PanelUpdateCheck | 'checking' | 'failed', label: (key: string) => string): string {
|
|
659
|
+
if (update === 'checking') return label('update-checking')
|
|
660
|
+
if (update === 'failed') return label('update-failed')
|
|
661
|
+
if (update.error !== undefined && update.error.length > 0) return update.error
|
|
662
|
+
const current = label('update-current').replace('{n}', update.current)
|
|
663
|
+
if (update.updateAvailable && update.latest !== null) {
|
|
664
|
+
return `${label('update-available')} ${label('update-newest').replace('{n}', update.latest)} · ${current}`
|
|
665
|
+
}
|
|
666
|
+
return `${label('update-ok')} ${current}`
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function probeSettings(
|
|
670
|
+
client: PanelSettingsClient,
|
|
671
|
+
id: string,
|
|
672
|
+
dispatch: (action: Parameters<typeof reduceSettings>[1]) => void,
|
|
673
|
+
): Promise<void> {
|
|
674
|
+
const result = await client.probe(id)
|
|
675
|
+
dispatch({ type: 'probed', message: result.message ?? (result.healthy ? 'probe-ok' : 'probe-failed') })
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function probeText(probe: string, label: (key: string) => string): string {
|
|
679
|
+
if (probe === 'update-failed' || probe === 'probe-ok' || probe === 'probe-failed') return label(probe)
|
|
680
|
+
return probe
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Loopback panel origin for the draft or saved host port. */
|
|
684
|
+
/** Bars from the theme file — not a hash of the palette id. */
|
|
685
|
+
function palettePreview(palette: PaletteChip): { primary: string; muted: string; card: string; surface: string } {
|
|
686
|
+
const css = palette.style ?? ''
|
|
687
|
+
const primary = tokenFromCss(css, 'primary') ?? palette.swatch ?? '#888888'
|
|
688
|
+
const muted = tokenFromCss(css, 'muted') ?? soften(primary, 0.55)
|
|
689
|
+
const card = tokenFromCss(css, 'card') ?? '#ffffff'
|
|
690
|
+
const surface = tokenFromCss(css, 'background') ?? soften(primary, 0.88)
|
|
691
|
+
return { primary, muted, card, surface }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function tokenFromCss(css: string, name: string): string | undefined {
|
|
695
|
+
const light = css.split(/\[data-mode=["']dark["']\]/)[0] ?? css
|
|
696
|
+
const match = new RegExp(`--${name}:\\s*([^;}]+)`).exec(light)
|
|
697
|
+
const value = match?.[1]?.trim()
|
|
698
|
+
return value !== undefined && value.length > 0 ? value : undefined
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function soften(color: string, mix: number): string {
|
|
702
|
+
return `color-mix(in oklab, ${color} ${Math.round(mix * 100)}%, white)`
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function paletteLabel(locale: PanelLocale, palette: PaletteChip): string {
|
|
706
|
+
if (locale === 'zh-CN' && palette.nameZh !== undefined && palette.nameZh.length > 0) return palette.nameZh
|
|
707
|
+
return palette.name
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function hostOrigin(port: string): string | undefined {
|
|
711
|
+
const n = Number(port)
|
|
712
|
+
if (!Number.isInteger(n) || n < 1024 || n > 65535) return undefined
|
|
713
|
+
return `http://127.0.0.1:${n}`
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Appearance / palette / card-style choice tile. Selected state must read at a glance. */
|
|
717
|
+
const choiceCardClass = [
|
|
718
|
+
'flex flex-col gap-2 rounded-xl border border-border/80 bg-card p-2 text-left outline-none transition-colors',
|
|
719
|
+
'hover:border-foreground/25',
|
|
720
|
+
'data-[on=1]:border-primary data-[on=1]:bg-primary/10 data-[on=1]:ring-2 data-[on=1]:ring-primary data-[on=1]:ring-offset-2 data-[on=1]:ring-offset-background',
|
|
721
|
+
'data-[on=1]:[&>span:last-child]:font-semibold',
|
|
722
|
+
].join(' ')
|