@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,154 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Dialog } from '../ui/dialog.tsx'
|
|
4
|
+
import { Tooltip } from '../ui/tooltip.tsx'
|
|
5
|
+
import { readAuthorMcpAgentIds, writeAuthorMcpAgentIds } from './author-mcp.ts'
|
|
6
|
+
import type { PanelAbout, PanelAuthorMcpStatus } from './client.ts'
|
|
7
|
+
import { installingCaption, shortHomePath, toggleId } from './install-row.tsx'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Authoring MCP dest list. One-click merge into assistant MCP files.
|
|
11
|
+
* Generate-snippet stays as a fallback.
|
|
12
|
+
* @param props - status loaders, about reader, and chrome labels
|
|
13
|
+
*/
|
|
14
|
+
export function AuthorMcpInstall(props: {
|
|
15
|
+
readonly label: (key: string) => string
|
|
16
|
+
readonly readAbout?: () => Promise<PanelAbout>
|
|
17
|
+
readonly readAuthorMcp?: () => Promise<PanelAuthorMcpStatus>
|
|
18
|
+
readonly installAuthorMcp?: (agentIds: readonly string[], description: string) => Promise<PanelAuthorMcpStatus>
|
|
19
|
+
readonly revealAuthorMcp?: (dest: string) => Promise<void>
|
|
20
|
+
}): ReactNode {
|
|
21
|
+
const [status, setStatus] = useState<PanelAuthorMcpStatus | undefined>(undefined)
|
|
22
|
+
const [busy, setBusy] = useState(false)
|
|
23
|
+
const [failed, setFailed] = useState(false)
|
|
24
|
+
const [picked, setPicked] = useState<ReadonlySet<string>>(() => new Set(readAuthorMcpAgentIds() ?? []))
|
|
25
|
+
const [snippetOpen, setSnippetOpen] = useState(false)
|
|
26
|
+
const [about, setAbout] = useState<PanelAbout | undefined>(undefined)
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (props.readAuthorMcp === undefined) return
|
|
29
|
+
void props.readAuthorMcp().then((next) => {
|
|
30
|
+
setStatus(next)
|
|
31
|
+
if (readAuthorMcpAgentIds() === undefined) {
|
|
32
|
+
setPicked(new Set(next.agents.filter(agent => agent.homePresent).map(agent => agent.id)))
|
|
33
|
+
}
|
|
34
|
+
}, () => setStatus(undefined))
|
|
35
|
+
}, [props.readAuthorMcp])
|
|
36
|
+
if (props.readAuthorMcp === undefined && props.readAbout === undefined) return null
|
|
37
|
+
return (
|
|
38
|
+
<div className="rounded-2xl border bg-card px-4 py-3">
|
|
39
|
+
<p className="m-0 text-sm font-medium">{props.label('mcp-authoring')}</p>
|
|
40
|
+
<p className="mt-1 mb-3 text-xs text-muted-foreground">{props.label('mcp-authoring-help')}</p>
|
|
41
|
+
{props.readAuthorMcp === undefined ? null : (
|
|
42
|
+
<ul className="m-0 mb-3 flex list-none flex-col gap-1 p-0">
|
|
43
|
+
{(status?.agents ?? []).map(agent => (
|
|
44
|
+
<li key={agent.id} className={agent.homePresent ? 'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-muted' : 'flex w-full items-center gap-2 rounded-lg px-2 py-1.5 opacity-50 hover:bg-muted'}>
|
|
45
|
+
<input type="checkbox" checked={picked.has(agent.id)} onChange={() => {
|
|
46
|
+
const next = toggleId(picked, agent.id)
|
|
47
|
+
setPicked(next)
|
|
48
|
+
writeAuthorMcpAgentIds([...next])
|
|
49
|
+
}} />
|
|
50
|
+
<span className="text-sm">{agent.label}</span>
|
|
51
|
+
<span className="ml-4 flex min-w-0 items-center gap-2">
|
|
52
|
+
{agent.homePresent ? null : <span className="text-[11px] text-muted-foreground">{props.label('skill-missing-home')}</span>}
|
|
53
|
+
{busy && picked.has(agent.id) ? installingCaption(props.label) : authorMcpCaption(agent, props.label)}
|
|
54
|
+
{agent.installed && props.revealAuthorMcp !== undefined ? (
|
|
55
|
+
<Tooltip text={agent.dest}><button type="button" className="max-w-[28rem] truncate font-mono text-[11px] text-muted-foreground underline-offset-2 hover:underline" onClick={() => { void props.revealAuthorMcp?.(agent.dest) }}>{shortHomePath(agent.dest)}</button></Tooltip>
|
|
56
|
+
) : null}
|
|
57
|
+
</span>
|
|
58
|
+
{agent.adapter === undefined ? null : (
|
|
59
|
+
<span className="ml-auto shrink-0 rounded-full border px-2 py-0.5 font-mono text-[10px] tracking-normal text-muted-foreground" data-adapter={agent.adapter}>{agent.adapter}</span>
|
|
60
|
+
)}
|
|
61
|
+
</li>
|
|
62
|
+
))}
|
|
63
|
+
</ul>
|
|
64
|
+
)}
|
|
65
|
+
<div className="flex flex-wrap gap-2">
|
|
66
|
+
{props.installAuthorMcp === undefined ? null : (
|
|
67
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm hover:bg-muted disabled:opacity-60" disabled={busy} onClick={() => {
|
|
68
|
+
const install = props.installAuthorMcp
|
|
69
|
+
if (install === undefined) return
|
|
70
|
+
setBusy(true)
|
|
71
|
+
setFailed(false)
|
|
72
|
+
void install([...picked], props.label('mcp-authoring-blurb')).then(setStatus, () => setFailed(true)).finally(() => setBusy(false))
|
|
73
|
+
}}>{busy ? '…' : props.label(authorMcpNeedsUpdate(status, picked) ? 'mcp-authoring-update-install' : 'mcp-authoring-install')}</button>
|
|
74
|
+
)}
|
|
75
|
+
{props.readAbout === undefined ? null : (
|
|
76
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm hover:bg-muted" onClick={() => {
|
|
77
|
+
const read = props.readAbout
|
|
78
|
+
if (read === undefined) return
|
|
79
|
+
void read().then((value) => {
|
|
80
|
+
setAbout(value)
|
|
81
|
+
setSnippetOpen(true)
|
|
82
|
+
}, () => setAbout(undefined))
|
|
83
|
+
}}>{props.label('mcp-authoring-generate')}</button>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
{failed ? <p className="mt-2 mb-0 text-xs text-destructive">{props.label('mcp-authoring-failed')}</p> : null}
|
|
87
|
+
{snippetOpen && about !== undefined ? (
|
|
88
|
+
<AuthoringDialog about={about} label={props.label} onClose={() => setSnippetOpen(false)} />
|
|
89
|
+
) : null}
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function authorMcpCaption(
|
|
95
|
+
row: { readonly installed: boolean; readonly updateAvailable: boolean },
|
|
96
|
+
label: (key: string) => string,
|
|
97
|
+
): ReactNode {
|
|
98
|
+
if (!row.installed) return null
|
|
99
|
+
return (
|
|
100
|
+
<span className={row.updateAvailable ? 'text-[11px] text-primary' : 'text-[11px] text-muted-foreground'}>
|
|
101
|
+
{label(row.updateAvailable ? 'skill-update' : 'skill-installed')}
|
|
102
|
+
</span>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function authorMcpNeedsUpdate(status: PanelAuthorMcpStatus | undefined, picked: ReadonlySet<string>): boolean {
|
|
107
|
+
if (status === undefined) return false
|
|
108
|
+
return status.agents.some(agent => picked.has(agent.id) && agent.updateAvailable)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function AuthoringDialog(props: {
|
|
112
|
+
readonly about: PanelAbout
|
|
113
|
+
readonly label: (key: string) => string
|
|
114
|
+
readonly onClose: () => void
|
|
115
|
+
}): ReactNode {
|
|
116
|
+
const [copied, setCopied] = useState(false)
|
|
117
|
+
const snippet = authoringSnippet(props.about.authoring.url, props.about.authoring.token, props.label('mcp-authoring-blurb'))
|
|
118
|
+
return (
|
|
119
|
+
<Dialog width="wide" onClose={props.onClose}>
|
|
120
|
+
<h5 className="m-0 text-base font-semibold">{props.label('mcp-authoring')}</h5>
|
|
121
|
+
<p className="mt-1 text-sm text-muted-foreground">{props.label('mcp-authoring-help')}</p>
|
|
122
|
+
<ul className="mt-4 max-h-56 overflow-auto rounded-xl border bg-muted/40 p-2">
|
|
123
|
+
{props.about.authoring.tools.map(tool => (
|
|
124
|
+
<li key={tool.name} className="rounded-lg px-3 py-2">
|
|
125
|
+
<p className="m-0 font-mono text-sm font-medium">{tool.name}</p>
|
|
126
|
+
<p className="mt-0.5 mb-0 text-xs leading-5 text-muted-foreground">{tool.description}</p>
|
|
127
|
+
</li>
|
|
128
|
+
))}
|
|
129
|
+
</ul>
|
|
130
|
+
<pre className="mt-4 overflow-auto rounded-xl border bg-muted p-3 font-mono text-xs">{snippet}</pre>
|
|
131
|
+
<div className="mt-3 flex justify-end gap-2">
|
|
132
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={() => {
|
|
133
|
+
void navigator.clipboard.writeText(snippet).then(() => {
|
|
134
|
+
setCopied(true)
|
|
135
|
+
window.setTimeout(() => setCopied(false), 1600)
|
|
136
|
+
})
|
|
137
|
+
}}>{copied ? props.label('mcp-copied') : props.label('mcp-copy')}</button>
|
|
138
|
+
<button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={props.onClose}>{props.label('confirm')}</button>
|
|
139
|
+
</div>
|
|
140
|
+
</Dialog>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function authoringSnippet(url: string, token: string, description: string): string {
|
|
145
|
+
return `${JSON.stringify({
|
|
146
|
+
'mini-app': {
|
|
147
|
+
url,
|
|
148
|
+
transport: 'streamable-http',
|
|
149
|
+
description,
|
|
150
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
151
|
+
_monkeyagent: { description },
|
|
152
|
+
},
|
|
153
|
+
}, null, 2)}\n`
|
|
154
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const selectedKey = 'mini-app.panel.mcp-agents'
|
|
2
|
+
|
|
3
|
+
/** Assistants remembered for the authoring MCP install. */
|
|
4
|
+
export function readAuthorMcpAgentIds(): string[] | undefined {
|
|
5
|
+
if (typeof localStorage === 'undefined') return undefined
|
|
6
|
+
try {
|
|
7
|
+
const raw = localStorage.getItem(selectedKey)
|
|
8
|
+
if (raw === null || raw.length === 0) return undefined
|
|
9
|
+
const parsed = JSON.parse(raw) as unknown
|
|
10
|
+
if (!Array.isArray(parsed)) return undefined
|
|
11
|
+
const ids = parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
|
12
|
+
return ids.length === 0 ? undefined : ids
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function writeAuthorMcpAgentIds(ids: readonly string[]): void {
|
|
19
|
+
try {
|
|
20
|
+
localStorage.setItem(selectedKey, JSON.stringify([...ids]))
|
|
21
|
+
} catch {
|
|
22
|
+
// A failed write still changes the current view.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/** Public host policy the settings form can read. No secrets. */
|
|
2
|
+
export interface PanelPolicy {
|
|
3
|
+
readonly theme: 'system' | 'light' | 'dark'
|
|
4
|
+
readonly palette: string
|
|
5
|
+
readonly locale: string
|
|
6
|
+
readonly chatLanguage: string
|
|
7
|
+
readonly hostPort: number
|
|
8
|
+
readonly llm: { readonly provider: string; readonly model: string } | null
|
|
9
|
+
readonly runtimeProvider: { readonly id: string }
|
|
10
|
+
readonly defaultWorkbenchId?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PanelPolicyWrite {
|
|
14
|
+
readonly policy: PanelPolicy
|
|
15
|
+
readonly restartRequired: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PanelProbe {
|
|
19
|
+
readonly healthy: boolean
|
|
20
|
+
readonly code?: string
|
|
21
|
+
readonly message?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Writing skill install status for one assistant or custom folder. */
|
|
25
|
+
export interface PanelSkillCopy {
|
|
26
|
+
readonly dest: string
|
|
27
|
+
readonly installed: boolean
|
|
28
|
+
readonly version: string | null
|
|
29
|
+
readonly updateAvailable: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PanelSkillAgent extends PanelSkillCopy {
|
|
33
|
+
readonly id: string
|
|
34
|
+
readonly label: string
|
|
35
|
+
readonly skillsDir: string
|
|
36
|
+
readonly homePresent: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PanelSkillStatus {
|
|
40
|
+
readonly skillId: string
|
|
41
|
+
readonly version: string | null
|
|
42
|
+
readonly agents: readonly PanelSkillAgent[]
|
|
43
|
+
readonly customs: readonly (PanelSkillCopy & { readonly dir: string })[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Authoring MCP install status for one assistant. */
|
|
47
|
+
export interface PanelAuthorMcpAgent {
|
|
48
|
+
readonly id: string
|
|
49
|
+
readonly label: string
|
|
50
|
+
readonly dest: string
|
|
51
|
+
readonly homePresent: boolean
|
|
52
|
+
readonly installed: boolean
|
|
53
|
+
readonly updateAvailable: boolean
|
|
54
|
+
readonly adapter?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PanelAuthorMcpStatus {
|
|
58
|
+
readonly agents: readonly PanelAuthorMcpAgent[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Owner about block, including the authoring MCP snippet. */
|
|
62
|
+
export interface PanelAbout {
|
|
63
|
+
readonly name: string
|
|
64
|
+
readonly current: string
|
|
65
|
+
readonly platform: string
|
|
66
|
+
readonly authoring: {
|
|
67
|
+
readonly url: string
|
|
68
|
+
readonly token: string
|
|
69
|
+
readonly tools: readonly { readonly name: string; readonly description: string }[]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Why an install did not finish. Closed: the panel owns the wording of each code. */
|
|
74
|
+
export const panelUpdateFailureCodes = ['timeout', 'exit', 'prepare', 'closed', 'leftover', 'verify', 'boot'] as const
|
|
75
|
+
|
|
76
|
+
export type PanelUpdateFailureCode = (typeof panelUpdateFailureCodes)[number]
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What the launcher recorded about the last install, in the runtime root.
|
|
80
|
+
* `from` is the version that was running when the attempt started, and `at` is epoch milliseconds.
|
|
81
|
+
*/
|
|
82
|
+
export type PanelUpdateAttempt =
|
|
83
|
+
| {
|
|
84
|
+
readonly state: 'done'
|
|
85
|
+
readonly from?: string
|
|
86
|
+
readonly to?: string
|
|
87
|
+
readonly at: number
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
readonly state: 'failed'
|
|
91
|
+
readonly code: PanelUpdateFailureCode
|
|
92
|
+
readonly from?: string
|
|
93
|
+
readonly to?: string
|
|
94
|
+
readonly rolledBack: boolean
|
|
95
|
+
readonly exitCode?: number
|
|
96
|
+
readonly log?: string
|
|
97
|
+
readonly at: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** One registry check. `latest` is missing when the registry did not answer. */
|
|
101
|
+
export interface PanelUpdateCheck {
|
|
102
|
+
readonly name: string
|
|
103
|
+
readonly current: string
|
|
104
|
+
readonly latest: string | null
|
|
105
|
+
readonly updateAvailable: boolean
|
|
106
|
+
readonly channel?: 'registry' | 'tarball'
|
|
107
|
+
readonly installable?: boolean
|
|
108
|
+
readonly error?: string
|
|
109
|
+
/** The last install this machine recorded, when one is on disk. The panel shows it once. */
|
|
110
|
+
readonly lastAttempt?: PanelUpdateAttempt
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One registered brain and the vendor/model pairs it publishes. */
|
|
114
|
+
export interface PanelRuntime {
|
|
115
|
+
readonly id: string
|
|
116
|
+
readonly label?: string
|
|
117
|
+
readonly models: readonly { readonly provider: string; readonly models: readonly string[] }[]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Settings calls. Absent means the settings entry is hidden. No route string lives here. */
|
|
121
|
+
export interface PanelSettingsClient {
|
|
122
|
+
readPolicy(): Promise<PanelPolicy>
|
|
123
|
+
writePolicy(policy: PanelPolicy): Promise<PanelPolicyWrite>
|
|
124
|
+
probe(id: string): Promise<PanelProbe>
|
|
125
|
+
readAbout?(): Promise<PanelAbout>
|
|
126
|
+
checkUpdate?(): Promise<PanelUpdateCheck>
|
|
127
|
+
installUpdate?(version: string): Promise<void>
|
|
128
|
+
/** Tell Host the launcher's last attempt was shown, so the next check does not open it again. */
|
|
129
|
+
ackUpdate?(at: number): Promise<void>
|
|
130
|
+
restartHost?(): Promise<void>
|
|
131
|
+
listRuntimes?(): Promise<readonly PanelRuntime[]>
|
|
132
|
+
readSkill?(customDirs?: readonly string[]): Promise<PanelSkillStatus>
|
|
133
|
+
installSkill?(agentIds: readonly string[], customDirs: readonly string[]): Promise<PanelSkillStatus>
|
|
134
|
+
revealSkill?(dest: string): Promise<void>
|
|
135
|
+
readAuthorMcp?(): Promise<PanelAuthorMcpStatus>
|
|
136
|
+
installAuthorMcp?(agentIds: readonly string[], description: string): Promise<PanelAuthorMcpStatus>
|
|
137
|
+
revealAuthorMcp?(dest: string): Promise<void>
|
|
138
|
+
/** Names, descriptions, and whether the store accepts a write. A secret never crosses this. */
|
|
139
|
+
readCredentials?(): Promise<PanelCredentials>
|
|
140
|
+
/**
|
|
141
|
+
* Save one account. An omitted secret keeps the one already stored, which is how the form edits a
|
|
142
|
+
* description without the owner pasting a secret again. Host reads the stored value; the panel
|
|
143
|
+
* never receives one.
|
|
144
|
+
*/
|
|
145
|
+
putCredential?(name: string, description: string, secret?: string): Promise<void>
|
|
146
|
+
removeCredential?(name: string): Promise<void>
|
|
147
|
+
listMcp?(): Promise<PanelMcpList>
|
|
148
|
+
writeMcp?(servers: readonly McpServerDraft[]): Promise<{ unresolved: readonly PanelMcpFailure[] }>
|
|
149
|
+
checkMcp?(server: McpServerDraft): Promise<McpCheckResult>
|
|
150
|
+
admitMcp?(text: string): Promise<readonly McpServerDraft[]>
|
|
151
|
+
importMcp?(source: string): Promise<readonly McpServerDraft[]>
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** One stored account, as the editor lists it. No secret: values are write-only. */
|
|
155
|
+
export interface PanelCredential {
|
|
156
|
+
readonly name: string
|
|
157
|
+
readonly description: string
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface PanelCredentials {
|
|
161
|
+
readonly credentials: readonly PanelCredential[]
|
|
162
|
+
/** False when the store is read-only, and then the form shows no write control. */
|
|
163
|
+
readonly writable: boolean
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One server the last boot left out, and why. */
|
|
167
|
+
export interface PanelMcpFailure {
|
|
168
|
+
readonly id: string
|
|
169
|
+
readonly code: string
|
|
170
|
+
readonly message: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface PanelMcpList {
|
|
174
|
+
readonly servers: readonly McpServerDraft[]
|
|
175
|
+
readonly unresolved: readonly PanelMcpFailure[]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One MCP server the settings section can edit. */
|
|
179
|
+
export interface McpServerDraft {
|
|
180
|
+
readonly id: string
|
|
181
|
+
readonly description?: string
|
|
182
|
+
readonly enabled?: boolean
|
|
183
|
+
readonly command?: string
|
|
184
|
+
readonly args?: readonly string[]
|
|
185
|
+
readonly env?: Readonly<Record<string, string>>
|
|
186
|
+
readonly url?: string
|
|
187
|
+
readonly transport?: 'sse' | 'streamable-http'
|
|
188
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** One tool a check listed, with the schemas the server declared. */
|
|
192
|
+
export interface McpToolRow {
|
|
193
|
+
readonly name: string
|
|
194
|
+
readonly description?: string
|
|
195
|
+
readonly inputSchema?: Record<string, unknown>
|
|
196
|
+
readonly outputSchema?: Record<string, unknown>
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Result of trying one server without saving. */
|
|
200
|
+
export interface McpCheckResult {
|
|
201
|
+
readonly ok: boolean
|
|
202
|
+
readonly tools: readonly McpToolRow[]
|
|
203
|
+
readonly code?: string
|
|
204
|
+
readonly message?: string
|
|
205
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { KeyRound } from 'lucide-react'
|
|
2
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import { panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
|
|
5
|
+
import { Dialog } from '../ui/dialog.tsx'
|
|
6
|
+
import type { PanelCredential, PanelSettingsClient } from './client.ts'
|
|
7
|
+
|
|
8
|
+
interface Draft {
|
|
9
|
+
readonly name: string
|
|
10
|
+
readonly description: string
|
|
11
|
+
readonly secret: string
|
|
12
|
+
/** The name being replaced, when the form edits a row instead of adding one. */
|
|
13
|
+
readonly editing: string | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const emptyDraft: Draft = { name: '', description: '', secret: '', editing: undefined }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Credentials settings. The list carries names and descriptions only: a secret is written once and
|
|
20
|
+
* never read back, so the form has no field that can show an existing value.
|
|
21
|
+
* A read-only store keeps the list and hides every write control.
|
|
22
|
+
* @param props - settings client and chrome locale
|
|
23
|
+
*/
|
|
24
|
+
export function CredentialSettings(props: {
|
|
25
|
+
readonly client: PanelSettingsClient
|
|
26
|
+
readonly locale: PanelLocale
|
|
27
|
+
readonly mode: PanelLabelMode
|
|
28
|
+
}): ReactNode {
|
|
29
|
+
const label = (key: string) => panelText(props.locale, key, props.mode)
|
|
30
|
+
const [rows, setRows] = useState<readonly PanelCredential[]>([])
|
|
31
|
+
const [writable, setWritable] = useState(false)
|
|
32
|
+
const [draft, setDraft] = useState<Draft | undefined>(undefined)
|
|
33
|
+
const [removing, setRemoving] = useState<string | undefined>(undefined)
|
|
34
|
+
const [busy, setBusy] = useState(false)
|
|
35
|
+
const [failed, setFailed] = useState('')
|
|
36
|
+
const [notice, setNotice] = useState('')
|
|
37
|
+
const loaded = useRef(false)
|
|
38
|
+
const read = props.client.readCredentials
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (read === undefined || loaded.current) return
|
|
42
|
+
loaded.current = true
|
|
43
|
+
const client = props.client
|
|
44
|
+
void read.call(client).then((next) => {
|
|
45
|
+
setRows(next.credentials)
|
|
46
|
+
setWritable(next.writable)
|
|
47
|
+
}, () => setFailed(label('credentials-load-failed')))
|
|
48
|
+
}, [read, props.client, label])
|
|
49
|
+
|
|
50
|
+
if (read === undefined) return <p className="text-sm text-muted-foreground">{label('credentials-empty')}</p>
|
|
51
|
+
|
|
52
|
+
const refresh = async (): Promise<void> => {
|
|
53
|
+
const next = await read.call(props.client)
|
|
54
|
+
setRows(next.credentials)
|
|
55
|
+
setWritable(next.writable)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const save = async (value: Draft): Promise<void> => {
|
|
59
|
+
const put = props.client.putCredential
|
|
60
|
+
if (put === undefined) return
|
|
61
|
+
setBusy(true)
|
|
62
|
+
setFailed('')
|
|
63
|
+
try {
|
|
64
|
+
// Blank means "keep the stored secret": Host resolves it, and the panel has no value to send.
|
|
65
|
+
const secret = value.secret.length === 0 ? undefined : value.secret
|
|
66
|
+
await put.call(props.client, value.name.trim(), value.description.trim(), secret)
|
|
67
|
+
await refresh()
|
|
68
|
+
setDraft(undefined)
|
|
69
|
+
setNotice(label(secret === undefined ? 'credentials-saved-description' : 'credentials-saved'))
|
|
70
|
+
} catch {
|
|
71
|
+
setFailed(label('credentials-save-failed'))
|
|
72
|
+
} finally {
|
|
73
|
+
setBusy(false)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const remove = async (name: string): Promise<void> => {
|
|
78
|
+
const drop = props.client.removeCredential
|
|
79
|
+
setRemoving(undefined)
|
|
80
|
+
if (drop === undefined) return
|
|
81
|
+
setBusy(true)
|
|
82
|
+
setFailed('')
|
|
83
|
+
try {
|
|
84
|
+
await drop.call(props.client, name)
|
|
85
|
+
await refresh()
|
|
86
|
+
setNotice('')
|
|
87
|
+
} catch {
|
|
88
|
+
setFailed(label('credentials-save-failed'))
|
|
89
|
+
} finally {
|
|
90
|
+
setBusy(false)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="flex flex-col gap-4">
|
|
96
|
+
<div className="flex items-start justify-between gap-3">
|
|
97
|
+
<div>
|
|
98
|
+
<h4 className="m-0">{label('section-credentials')}</h4>
|
|
99
|
+
{rows.length === 0 ? <p className="mt-1 text-xs text-muted-foreground">{label('credentials-empty')}</p> : null}
|
|
100
|
+
<p className="mt-1 text-xs text-muted-foreground">{label('mcp-reference-hint')}</p>
|
|
101
|
+
</div>
|
|
102
|
+
{writable ? (
|
|
103
|
+
<button type="button" className="h-8 shrink-0 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={() => setDraft(emptyDraft)}>
|
|
104
|
+
{label('credentials-add')}
|
|
105
|
+
</button>
|
|
106
|
+
) : <span className="shrink-0 text-xs text-muted-foreground">{label('credentials-read-only')}</span>}
|
|
107
|
+
</div>
|
|
108
|
+
<ul className="flex flex-col gap-2">
|
|
109
|
+
{rows.map(row => (
|
|
110
|
+
<li key={row.name} className="flex items-start gap-3 rounded-xl border bg-card px-3 py-2">
|
|
111
|
+
<span className="mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-lg border text-muted-foreground">
|
|
112
|
+
<KeyRound size={14} />
|
|
113
|
+
</span>
|
|
114
|
+
<div className="min-w-0 flex-1">
|
|
115
|
+
<p className="m-0 truncate text-sm font-medium">{row.name}</p>
|
|
116
|
+
<p className="m-0 truncate text-xs text-muted-foreground">{row.description}</p>
|
|
117
|
+
<p className="m-0 mt-1 font-mono text-[11px] text-muted-foreground">{label('credentials-reference').replace('{reference}', `\${credential:${row.name}}`)}</p>
|
|
118
|
+
</div>
|
|
119
|
+
{writable ? (
|
|
120
|
+
<div className="flex shrink-0 gap-1">
|
|
121
|
+
<button type="button" className="h-7 rounded-md px-2 text-xs hover:bg-muted" onClick={() => setDraft({ name: row.name, description: row.description, secret: '', editing: row.name })}>
|
|
122
|
+
{label('credentials-edit')}
|
|
123
|
+
</button>
|
|
124
|
+
<button type="button" className="h-7 rounded-md px-2 text-xs text-destructive hover:bg-muted" onClick={() => setRemoving(row.name)}>
|
|
125
|
+
{label('delete')}
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
) : null}
|
|
129
|
+
</li>
|
|
130
|
+
))}
|
|
131
|
+
</ul>
|
|
132
|
+
{notice.length > 0 ? <p className="text-xs text-muted-foreground">{notice}</p> : null}
|
|
133
|
+
{failed.length > 0 ? <p className="text-xs text-destructive">{failed}</p> : null}
|
|
134
|
+
{draft === undefined ? null : (
|
|
135
|
+
<Dialog onClose={() => setDraft(undefined)}>
|
|
136
|
+
<h4 className="m-0">{label(draft.editing === undefined ? 'credentials-new' : 'credentials-edit')}</h4>
|
|
137
|
+
<div className="mt-4 flex flex-col gap-3">
|
|
138
|
+
<Field label={label('credentials-name')} hint={label('credentials-name-hint')} value={draft.name} disabled={draft.editing !== undefined} onChange={name => setDraft({ ...draft, name })} />
|
|
139
|
+
<Field label={label('credentials-description')} hint={label('credentials-desc-hint')} value={draft.description} onChange={description => setDraft({ ...draft, description })} />
|
|
140
|
+
<Field label={label('credentials-secret')} hint={label('credentials-secret-hint')} value={draft.secret} password onChange={secret => setDraft({ ...draft, secret })} />
|
|
141
|
+
{draft.editing === undefined ? null : <p className="m-0 text-xs text-muted-foreground">{label('credentials-replace-hint')}</p>}
|
|
142
|
+
</div>
|
|
143
|
+
<div className="mt-4 flex items-center justify-end gap-2">
|
|
144
|
+
{draft.name.trim().length === 0
|
|
145
|
+
? <span className="mr-auto text-xs text-muted-foreground">{label('credentials-need-name')}</span>
|
|
146
|
+
: draft.editing === undefined && draft.secret.length === 0
|
|
147
|
+
? <span className="mr-auto text-xs text-muted-foreground">{label('credentials-need-secret')}</span>
|
|
148
|
+
: null}
|
|
149
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={() => setDraft(undefined)}>{label('cancel')}</button>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground disabled:opacity-50"
|
|
153
|
+
disabled={busy || draft.name.trim().length === 0 || (draft.editing === undefined && draft.secret.length === 0)}
|
|
154
|
+
onClick={() => { void save(draft) }}
|
|
155
|
+
>
|
|
156
|
+
{label('credentials-save')}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
</Dialog>
|
|
160
|
+
)}
|
|
161
|
+
{removing === undefined ? null : (
|
|
162
|
+
<Dialog width="sm" onClose={() => setRemoving(undefined)}>
|
|
163
|
+
<p className="m-0 text-sm">{label('credentials-delete-confirm')}</p>
|
|
164
|
+
<div className="mt-4 flex justify-end gap-2">
|
|
165
|
+
<button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={() => setRemoving(undefined)}>{label('cancel')}</button>
|
|
166
|
+
<button type="button" className="h-8 rounded-lg bg-destructive px-3 text-sm font-semibold text-destructive-foreground" onClick={() => { void remove(removing) }}>{label('delete')}</button>
|
|
167
|
+
</div>
|
|
168
|
+
</Dialog>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function Field(props: {
|
|
175
|
+
readonly label: string
|
|
176
|
+
readonly hint: string
|
|
177
|
+
readonly value: string
|
|
178
|
+
readonly password?: boolean
|
|
179
|
+
readonly disabled?: boolean
|
|
180
|
+
readonly onChange: (value: string) => void
|
|
181
|
+
}): ReactNode {
|
|
182
|
+
return (
|
|
183
|
+
<label className="flex flex-col gap-1 text-xs">
|
|
184
|
+
<span className="text-muted-foreground">{props.label}</span>
|
|
185
|
+
<input
|
|
186
|
+
type={props.password === true ? 'password' : 'text'}
|
|
187
|
+
className="h-8 rounded-lg border bg-background px-2 text-sm text-foreground disabled:opacity-60"
|
|
188
|
+
placeholder={props.hint}
|
|
189
|
+
value={props.value}
|
|
190
|
+
disabled={props.disabled === true}
|
|
191
|
+
onChange={event => props.onChange(event.target.value)}
|
|
192
|
+
/>
|
|
193
|
+
</label>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isPanelLocale, type PanelLocale } from '../labels.ts'
|
|
2
|
+
|
|
3
|
+
/** Settings port range. Locked in the product decisions, not a host tunable. */
|
|
4
|
+
export const panelPortBound = { min: 1024, max: 65535 } as const
|
|
5
|
+
|
|
6
|
+
export type PanelPortResult =
|
|
7
|
+
| { readonly ok: true; readonly port: number }
|
|
8
|
+
| { readonly ok: false; readonly key: 'port-invalid' }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Admit a settings port before any write. A bad value is not sent to Host.
|
|
12
|
+
* @param value - the form value
|
|
13
|
+
*/
|
|
14
|
+
export function admitPanelPort(value: unknown): PanelPortResult {
|
|
15
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) return { ok: false, key: 'port-invalid' }
|
|
16
|
+
if (value < panelPortBound.min || value > panelPortBound.max) return { ok: false, key: 'port-invalid' }
|
|
17
|
+
return { ok: true, port: value }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The one language control. Both fields are the same locale.
|
|
22
|
+
* @param locale - `en` or `zh-CN`
|
|
23
|
+
*/
|
|
24
|
+
export function panelLanguageFields(locale: string): { locale: PanelLocale; chatLanguage: PanelLocale } {
|
|
25
|
+
if (!isPanelLocale(locale)) throw new Error(`panel locale is not supported: ${locale}`)
|
|
26
|
+
return { locale, chatLanguage: locale }
|
|
27
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Loader2 } from 'lucide-react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
/** Toggle one id in a remembered pick set. */
|
|
5
|
+
export function toggleId(current: ReadonlySet<string>, id: string): ReadonlySet<string> {
|
|
6
|
+
const next = new Set(current)
|
|
7
|
+
if (next.has(id)) next.delete(id)
|
|
8
|
+
else next.add(id)
|
|
9
|
+
return next
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Home-relative dest shown next to the status caption. */
|
|
13
|
+
export function shortHomePath(dest: string): string {
|
|
14
|
+
const unix = dest.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/)
|
|
15
|
+
if (unix !== null && unix[1] !== undefined) return `~${dest.slice(unix[1].length)}`
|
|
16
|
+
const windows = dest.match(/^[A-Za-z]:\\Users\\[^\\]+/)
|
|
17
|
+
if (windows !== null && windows[0] !== undefined) return `~${dest.slice(windows[0].length).replace(/\\/g, '/')}`
|
|
18
|
+
return dest
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Custom dests must end with a `skills` folder. */
|
|
22
|
+
export function endsWithSkills(raw: string): boolean {
|
|
23
|
+
const text = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '')
|
|
24
|
+
return text === 'skills' || text.endsWith('/skills')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Caption while a picked dest is being written. */
|
|
28
|
+
export function installingCaption(label: (key: string) => string): ReactNode {
|
|
29
|
+
return (
|
|
30
|
+
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
|
31
|
+
<Loader2 size={11} className="animate-spin" />
|
|
32
|
+
{label('installing')}
|
|
33
|
+
</span>
|
|
34
|
+
)
|
|
35
|
+
}
|