fix(desktop): keep a saved key to its service, do not repeat job events, show attachment previews

The model settings claimed a saved key and asked the node to reuse it
whatever provider or address was now chosen. They now track the service
the key was saved for. Job event streams resume after the last event
shown, so reopening them repeats nothing. Image previews made from blob
URLs are allowed by the page's content policy. The docs no longer speak
of a Rust daemon.
This commit is contained in:
rvnminers-A-and-N committed 2026-10-09 08:12:51 +00:00
1 parent bb464148f6
commit dec424ec2a
6 files changed
+30 -12

No files matched your search

+1 -1
View File
@@ -5,6 +5,6 @@
- Treat cross-platform behavior as a design requirement for daemon code. Keep domain logic independent of operating-system-specific paths and APIs.
- The daemon is `apps/sisyphusd`, written in Go. Build it with `make build`; run `bin/sisyphusd run`. The earlier Rust implementation was retired; do not restore it as the development runtime.
- Never hardcode home-directory paths, path separators, or assumptions about a writable current directory. Use Go's `path/filepath` and the existing daemon data-directory helpers.
- Put persistent node data in the OS-appropriate application data directory. The SQLite database contains the private node identity, so protect the database and its containing directory with restrictive permissions where the platform supports them; rely on the user's inherited ACL on Windows unless a dedicated ACL implementation is introduced.
- Put persistent node data in the OS-appropriate application data directory. The node's private key is the file `node.key` in that directory, and the SQLite database beside it holds its pool's members and jobs, so protect the directory and both files with restrictive permissions where the platform supports them; rely on the user's inherited ACL on Windows unless a dedicated ACL implementation is introduced.
- Isolate unavoidable OS-specific behavior behind small helpers and Go build tags. Keep a portable fallback where possible.
- Add tests for path handling and platform-specific behavior. Verify supported targets in CI before claiming support; the Nix dev shell currently targets Linux and macOS.
+1 -1
View File
@@ -33,7 +33,7 @@ The Electron main process is the gRPC client. It reads the shared `proto/sisyphu
The desktop client connects to one local daemon. It shows live node and peer status, manages peer compute permissions, and exposes the Go daemon's chat/model, job, file, and pool APIs. Chat model-provider configuration and credentials belong to the local daemon; the desktop UI does not call model providers directly. Files are limited to 256 MiB in this client and are sent to the daemon in bounded gRPC chunks.
This is still a development client, not a packaged product or remotely accessible Web UI. It does not start or manage the daemon process: start `sisyphusd` separately. Public-node browsing, remote authentication, daemon lifecycle management, and production packaging remain future work. The Rust libp2p daemon does not implement the Go daemon's job, chat, file, and pool API surface.
This is still a development client, not a packaged product or remotely accessible Web UI. It does not start or manage the daemon process: start `sisyphusd` separately. Public-node browsing, remote authentication, daemon lifecycle management, and production packaging remain future work.
Run checks with:
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:"
/>
<title>Sisyphus</title>
</head>
@@ -35,6 +35,7 @@ export function OperationsPage({ messages }: { messages: Messages }) {
const [tab, setTab] = useState<'jobs' | 'files' | 'pool'>('jobs')
const [jobs, setJobs] = useState<Job[]>([])
const [jobEvents, setJobEvents] = useState<Record<string, { kind: string; text: string; workerName: string }[]>>({})
const lastEventSeq = useRef<Record<string, number>>({})
const [files, setFiles] = useState<StoredFile[]>([])
const [members, setMembers] = useState<Member[]>([])
const [workload, setWorkload] = useState('primes')
@@ -120,7 +121,14 @@ export function OperationsPage({ messages }: { messages: Messages }) {
const liveJobIds = displayedJobs.filter((job) => ['JOB_STATE_PENDING', 'JOB_STATE_RUNNING', 1, 2].includes(job.state)).map((job) => job.jobId).join(',')
useEffect(() => {
const ids = liveJobIds ? liveJobIds.split(',') : []
const stops = ids.map((jobId) => getNodeApi().stream<{ kind: string; text: string; workerName: string }>('watchJobEvents', { jobId }, (event) => setJobEvents((current) => ({ ...current, [jobId]: [...(current[jobId] ?? []).slice(-19), event] })), () => {}))
// Each stream picks up after the last event already shown, so that
// reopening them when the set of live jobs changes repeats nothing.
const stops = ids.map((jobId) => getNodeApi().stream<{ seq?: string | number; kind: string; text: string; workerName: string }>('watchJobEvents', { jobId, afterSeq: lastEventSeq.current[jobId] ?? 0 }, (event) => {
const seq = Number(event.seq ?? 0)
if (seq && seq <= (lastEventSeq.current[jobId] ?? 0)) return
if (seq) lastEventSeq.current[jobId] = seq
setJobEvents((current) => ({ ...current, [jobId]: [...(current[jobId] ?? []).slice(-19), event] }))
}, () => {}))
return () => stops.forEach((stop) => stop())
}, [liveJobIds])
async function uploadFile(file?: File) {
@@ -31,7 +31,13 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
const [pulling, setPulling] = useState(false)
const pullCancel = useRef<(() => void) | null>(null)
const currentProvider = providers.find((item) => item.id === provider)
const keepApiKey = !apiKey && hasApiKey && provider === currentProvider?.id
// The node keeps a key for one service: the provider and address it was
// saved with. Pointed anywhere else, this page must neither claim a key
// is saved nor ask the node to reuse it.
const [savedService, setSavedService] = useState({ provider: '', baseUrl: '' })
const sameAddress = (a: string, b: string, fallback: string) => (a || fallback).replace(/\/+$/, '') === (b || fallback).replace(/\/+$/, '')
const keyIsForThisService = hasApiKey && provider === savedService.provider && sameAddress(baseUrl, savedService.baseUrl, currentProvider?.defaultUrl ?? '')
const keepApiKey = !apiKey && keyIsForThisService
useEffect(() => {
let active = true
@@ -47,6 +53,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
setBaseUrl(config.baseUrl || selectedProvider?.defaultUrl || '')
setModel(config.model ?? '')
setHasApiKey(Boolean(config.hasApiKey))
setSavedService({ provider: config.provider ?? '', baseUrl: config.baseUrl ?? '' })
}).catch(() => { /* The local daemon may not be running yet. */ })
return () => { active = false; pullCancel.current?.() }
}, [])
@@ -55,7 +62,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
if (!provider) return
setLoadingModels(true)
try {
const result = await getNodeApi().call<{ models?: string[]; details?: typeof models }>('listModels', { service: { provider, baseUrl, apiKey, keepApiKey: !apiKey && hasApiKey } })
const result = await getNodeApi().call<{ models?: string[]; details?: typeof models }>('listModels', { service: { provider, baseUrl, apiKey, keepApiKey } })
const details = result.details ?? (result.models ?? []).map((name) => ({ name, label: '', sizeBytes: 0, tools: 0 }))
setModels(details)
if (details.some((item) => item.name === model)) return
@@ -67,8 +74,11 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
async function saveModel() {
setSavingModel(true)
try {
const result = await getNodeApi().call<{ hasApiKey?: boolean }>('setModelConfig', { provider, baseUrl, model, apiKey, keepApiKey: !apiKey && hasApiKey })
setHasApiKey(Boolean(result.hasApiKey ?? (apiKey || hasApiKey)))
const result = await getNodeApi().call<{ hasApiKey?: boolean }>('setModelConfig', { provider, baseUrl, model, apiKey, keepApiKey })
// The node says whether a key is now saved: it drops the old one
// when the service changed and no new one was given.
setHasApiKey(Boolean(result.hasApiKey))
setSavedService({ provider, baseUrl })
setApiKey('')
toast.success('Model configuration saved')
} catch (error) {
@@ -80,7 +90,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
setPullStatus('Starting download…')
setPullProgress(0)
setPulling(true)
pullCancel.current = getNodeApi().stream<{ status: string; completedBytes?: string | number; totalBytes?: string | number }>('pullModel', { service: { provider, baseUrl, apiKey, keepApiKey: !apiKey && hasApiKey }, model: downloadModel.trim() }, (event) => {
pullCancel.current = getNodeApi().stream<{ status: string; completedBytes?: string | number; totalBytes?: string | number }>('pullModel', { service: { provider, baseUrl, apiKey, keepApiKey }, model: downloadModel.trim() }, (event) => {
setPullStatus(event.status)
const total = Number(event.totalBytes ?? 0)
const complete = Number(event.completedBytes ?? 0)
@@ -106,7 +116,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
}
async function removeModel(name: string) {
try {
await getNodeApi().call('removeModel', { service: { provider, baseUrl, apiKey, keepApiKey: !apiKey && hasApiKey }, model: name })
await getNodeApi().call('removeModel', { service: { provider, baseUrl, apiKey, keepApiKey }, model: name })
setModels((current) => current.filter((item) => item.name !== name))
toast.success('Model removed')
} catch (error) { toast.error('Could not remove model', { description: error instanceof Error ? error.message : String(error) }) }
@@ -145,7 +155,7 @@ export function SettingsPage({ locale, setLocale, messages, direction }: { local
<Card><CardHeader className="flex flex-row items-center gap-3 border-b border-[var(--app-line)] py-4"><div className="grid size-9 place-items-center rounded-xl bg-[var(--app-wash)]"><ServerCog className="size-4 text-muted-foreground" /></div><div><div className="text-[9px] font-semibold tracking-[0.13em] text-muted-foreground">LOCAL AI</div><CardTitle className="mt-0.5 text-sm">Model provider</CardTitle></div><Badge variant="outline" className="ms-auto">Stored on node</Badge></CardHeader><CardContent className="space-y-4 pt-4">
<p className="text-xs text-muted-foreground">Choose the model provider used by the planner. API keys are sent directly to the local daemon and are never displayed again.</p>
<div className="grid gap-3 sm:grid-cols-2"><label className="space-y-1.5 text-xs"><span>Provider</span><select value={provider} onChange={(event) => { const next = providers.find((item) => item.id === event.target.value); setProvider(event.target.value); if (next) setBaseUrl(next.defaultUrl); setModels([]) }} className="h-10 w-full rounded-xl border border-[var(--app-line)] bg-background px-3 text-sm">{providers.length === 0 && <option value="">Daemon unavailable</option>}{providers.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}</select></label><label className="space-y-1.5 text-xs"><span>Service URL</span><Input dir="ltr" value={baseUrl} onChange={(event) => setBaseUrl(event.target.value)} placeholder={currentProvider?.defaultUrl || 'http://localhost:11434'} /></label></div>
{currentProvider?.needsKey !== 2 && currentProvider?.needsKey !== 'SUPPORT_NO' && <label className="block space-y-1.5 text-xs"><span>API key {hasApiKey && !apiKey ? '· saved on node' : ''}</span><Input dir="ltr" type="password" autoComplete="new-password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} placeholder={hasApiKey ? 'Leave empty to keep saved key' : 'Enter provider API key'} /></label>}
{currentProvider?.needsKey !== 2 && currentProvider?.needsKey !== 'SUPPORT_NO' && <label className="block space-y-1.5 text-xs"><span>API key {keyIsForThisService && !apiKey ? '· saved on node' : ''}</span><Input dir="ltr" type="password" autoComplete="new-password" value={apiKey} onChange={(event) => setApiKey(event.target.value)} placeholder={keyIsForThisService ? 'Leave empty to keep saved key' : 'Enter provider API key'} /></label>}
<div className="flex flex-wrap items-center gap-2"><Button type="button" variant="outline" disabled={!provider || loadingModels} onClick={() => void refreshModels()} className="gap-2">{loadingModels ? <LoaderCircle className="size-4 animate-spin" /> : null}Load models</Button><select aria-label="Model" value={model} onChange={(event) => setModel(event.target.value)} className="h-10 min-w-48 flex-1 rounded-xl border border-[var(--app-line)] bg-background px-3 text-sm"><option value="">Choose model</option>{models.map((item) => <option key={item.name} value={item.name} disabled={item.tools === 'SUPPORT_NO' || item.tools === 2}>{item.label || item.name}{Number(item.sizeBytes) > 0 ? ` · ${(Number(item.sizeBytes) / 1024 ** 3).toFixed(1)} GB` : ''}</option>)}</select><Button type="button" disabled={!provider || !model || savingModel} onClick={() => void saveModel()}>{savingModel ? 'Saving…' : 'Save model'}</Button></div>
{models.length > 0 && <div className="space-y-1 rounded-xl border border-[var(--app-line)] px-3 py-2">{models.map((item) => <div key={item.name} className="flex items-center gap-2 py-1 text-xs"><span className="min-w-0 flex-1 truncate">{item.label || item.name}</span>{item.tools === 'SUPPORT_NO' || item.tools === 2 ? <Badge variant="outline">No tools</Badge> : null}{currentProvider?.fetchesModels && <Button type="button" size="icon" variant="ghost" className="size-7" aria-label={`Remove ${item.name}`} onClick={() => void removeModel(item.name)}><Trash2 className="size-3.5" /></Button>}</div>)}</div>}
{currentProvider?.fetchesModels && <div className="flex flex-wrap items-center gap-2 border-t border-[var(--app-line)] pt-3"><Input value={downloadModel} onChange={(event) => setDownloadModel(event.target.value)} placeholder="Model name to download (e.g. llama3.1:8b)" className="min-w-48 flex-1" /><Button type="button" variant="outline" disabled={pulling || !downloadModel.trim()} onClick={startModelPull} className="gap-2"><Download className="size-4" />Download</Button>{pulling && <Button type="button" variant="ghost" onClick={cancelModelPull}>Cancel</Button>}</div>}
+1 -1
View File
@@ -8,7 +8,7 @@ This is the walking skeleton: a coordinator splits a job into tasks, workers exe
Needs Go 1.27 or newer.
From the repository root, `nix develop` provides the Go toolchain and development dependencies. The active daemon is the Go binary at `bin/sisyphusd`; the earlier Rust binary under `target/debug` is not used for development.
From the repository root, `nix develop` provides the Go toolchain and development dependencies. The daemon is the Go binary at `bin/sisyphusd`.
```sh
make build # produces bin/sisyphusd