diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 17:07:31 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 17:07:31 +0900 |
| commit | 288b21cec98421fda57028a0c8c9d835cfbb14b0 (patch) | |
| tree | 9ce3ec38acdcf7be96e28f1e0d5deccf6b46c917 | |
| parent | 45a4890031192f4e7409443f98e824dad17ba175 (diff) | |
| download | dispatch-288b21cec98421fda57028a0c8c9d835cfbb14b0.tar.gz dispatch-288b21cec98421fda57028a0c8c9d835cfbb14b0.zip | |
feat: add/remove keys from UI, backend URL setting, user service, Docker fix
- Add POST /models/add-key and POST /models/remove-key API endpoints
- Add 'Add New Key' modal (page-level) with provider selection
- Add remove button per key in Model Status view
- Add configurable backend URL setting in Settings panel with localStorage persistence
- Convert systemd service from system to user service (systemctl --user)
- Fix Docker entrypoint to chown all nested node_modules dirs
- Update dispatch.toml credential paths to use -pro/-max naming
- Make API port configurable via PORT env var (default 3000, prod 18390)
| -rw-r--r-- | dispatch.toml | 4 | ||||
| -rw-r--r-- | docker/entrypoint.dev.sh | 6 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 124 | ||||
| -rw-r--r-- | packages/frontend/src/App.svelte | 80 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ModelStatus.svelte | 49 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 46 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 4 | ||||
| -rw-r--r-- | packages/frontend/src/lib/config.ts | 36 | ||||
| -rw-r--r-- | packaging/PKGBUILD | 8 | ||||
| -rw-r--r-- | packaging/dispatch-api.service | 20 | ||||
| -rw-r--r-- | packaging/dispatch.install | 4 |
11 files changed, 342 insertions, 39 deletions
diff --git a/dispatch.toml b/dispatch.toml index 4332462..72ff9bb 100644 --- a/dispatch.toml +++ b/dispatch.toml @@ -8,13 +8,13 @@ id = "claude-pro" provider = "anthropic" base_url = "https://api.anthropic.com/v1" -credentials_file = "/home/tradam/.claude/.credentials-1.json" +credentials_file = "/home/tradam/.claude/.credentials-pro.json" [[keys]] id = "claude-max" provider = "anthropic" base_url = "https://api.anthropic.com/v1" -credentials_file = "/home/tradam/.claude/.credentials-2.json" +credentials_file = "/home/tradam/.claude/.credentials-max.json" [[keys]] id = "opencode-1" diff --git a/docker/entrypoint.dev.sh b/docker/entrypoint.dev.sh index bbde09a..dd0d423 100644 --- a/docker/entrypoint.dev.sh +++ b/docker/entrypoint.dev.sh @@ -35,10 +35,8 @@ if [ -d "$USER_HOME/.claude" ]; then chown -R "$HOST_UID:$HOST_GID" "$USER_HOME/.claude" 2>/dev/null || true fi -# Ensure node_modules is writable (created as root during build) -if [ -d /app/node_modules ]; then - chown -R "$HOST_UID:$HOST_GID" /app/node_modules -fi +# Ensure all node_modules are writable (created as root during build) +find /app -name node_modules -type d -maxdepth 3 -exec chown -R "$HOST_UID:$HOST_GID" {} + 2>/dev/null || true # Install/update dependencies as the target user (skip with SKIP_INSTALL=1) if [ "${SKIP_INSTALL:-}" != "1" ]; then diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 1daf37e..d6b82c2 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -1,3 +1,5 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import type { ModelRegistry } from "@dispatch/core"; import { ANTHROPIC_MODELS_FALLBACK, @@ -396,6 +398,128 @@ modelsRoutes.get("/credentials-status", (c) => { return c.json({ credentials: status }); }); +// ─── Add key to dispatch.toml ───────────────────────────────── + +const VALID_PROVIDERS = ["anthropic", "opencode-go", "github-copilot"] as const; +type SupportedProvider = (typeof VALID_PROVIDERS)[number]; + +const PROVIDER_BASE_URLS: Record<SupportedProvider, string> = { + anthropic: "https://api.anthropic.com/v1", + "opencode-go": "https://opencode.ai/zen/go/v1", + "github-copilot": "https://api.githubcopilot.com", +}; + +modelsRoutes.post("/add-key", async (c) => { + const body = await c.req.json<{ id?: unknown; provider?: unknown }>(); + + // Validate id + if (typeof body.id !== "string" || !body.id.trim() || !/^[a-zA-Z0-9_-]+$/.test(body.id.trim())) { + return c.json({ error: "id must contain only letters, numbers, dashes, and underscores" }, 400); + } + const id = body.id.trim(); + + // Validate provider + if (!VALID_PROVIDERS.includes(body.provider as SupportedProvider)) { + return c.json( + { error: `provider must be one of: ${VALID_PROVIDERS.join(", ")}` }, + 400, + ); + } + const provider = body.provider as SupportedProvider; + const base_url = PROVIDER_BASE_URLS[provider]; + + // Read current dispatch.toml + const tomlPath = `${process.cwd()}/dispatch.toml`; + let tomlContent: string; + try { + tomlContent = readFileSync(tomlPath, "utf-8"); + } catch (err) { + return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500); + } + + // Check for duplicate key id + const idPattern = new RegExp(`^\\s*id\\s*=\\s*["']?${id}["']?\\s*$`, "m"); + if (idPattern.test(tomlContent)) { + return c.json({ error: `key with id "${id}" already exists` }, 409); + } + + // Build the new [[keys]] block + let newBlock = `\n[[keys]]\nid = "${id}"\nprovider = "${provider}"\nbase_url = "${base_url}"`; + if (provider === "anthropic") { + const credPath = `${homedir()}/.claude/.credentials-${id}.json`; + newBlock += `\ncredentials_file = "${credPath}"`; + } + newBlock += "\n"; + + // Insert before the # ─── Permissions section if it exists, otherwise at end + const permissionsMarker = /\n# [─\-]+ Permissions/; + let newContent: string; + const permMatch = permissionsMarker.exec(tomlContent); + if (permMatch) { + const insertAt = permMatch.index; + newContent = tomlContent.slice(0, insertAt) + newBlock + tomlContent.slice(insertAt); + } else { + newContent = tomlContent + newBlock; + } + + try { + writeFileSync(tomlPath, newContent, "utf-8"); + } catch (err) { + return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500); + } + + const key: { id: string; provider: string; base_url: string; credentials_file?: string } = { + id, + provider, + base_url, + }; + if (provider === "anthropic") { + key.credentials_file = `${homedir()}/.claude/.credentials-${id}.json`; + } + + return c.json({ success: true, key }); +}); + +// ─── Remove key from dispatch.toml ──────────────────────────── + +modelsRoutes.post("/remove-key", async (c) => { + const body = await c.req.json<{ id?: unknown }>(); + + if (typeof body.id !== "string" || !body.id.trim()) { + return c.json({ error: "id is required" }, 400); + } + const id = body.id.trim(); + + const tomlPath = `${process.cwd()}/dispatch.toml`; + let tomlContent: string; + try { + tomlContent = readFileSync(tomlPath, "utf-8"); + } catch (err) { + return c.json({ error: `failed to read dispatch.toml: ${String(err)}` }, 500); + } + + // Match the [[keys]] block containing this id and remove it. + // A block starts with [[keys]] and ends at the next [[...]] header, # ─── section marker, or EOF. + const blockPattern = new RegExp( + `\\n?\\[\\[keys\\]\\]\\n(?:[^\\[#]|#(?! [─\\-]))*?id\\s*=\\s*"${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^\\[#]*(?:\\n(?=\\[|# [─\\-])|$)`, + "s", + ); + const match = blockPattern.exec(tomlContent); + if (!match) { + return c.json({ error: `key "${id}" not found in dispatch.toml` }, 404); + } + + const newContent = tomlContent.slice(0, match.index) + tomlContent.slice(match.index + match[0].length); + + try { + writeFileSync(tomlPath, newContent, "utf-8"); + } catch (err) { + return c.json({ error: `failed to write dispatch.toml: ${String(err)}` }, 500); + } + + return c.json({ success: true }); +}); + // ─── Shared wake function ───────────────────────────────────── async function wakeAllClaudeAccounts(): Promise< diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 33415bd..d96cb63 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -22,6 +22,40 @@ let modelsData = $state<{ keys: KeyInfo[] }>({ let sidebarOpen = $state(true); +// Add Key modal state (rendered at page level to escape sidebar transform) +let showAddKeyModal = $state(false); +let addKeyProvider = $state("anthropic"); +let addKeyId = $state(""); +let addKeyError = $state<string | null>(null); +let addKeySaving = $state(false); + +async function addNewKey(): Promise<void> { + if (!addKeyId.trim()) return; + addKeySaving = true; + addKeyError = null; + try { + const res = await fetch(`${config.apiBase}/models/add-key`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: addKeyId.trim(), provider: addKeyProvider }), + }); + const data = (await res.json()) as { success?: boolean; error?: string }; + if (!res.ok || !data.success) { + addKeyError = data.error ?? "Failed to add key"; + } else { + showAddKeyModal = false; + addKeyId = ""; + addKeyProvider = "anthropic"; + await new Promise((r) => setTimeout(r, 500)); + window.location.reload(); + } + } catch (e) { + addKeyError = e instanceof Error ? e.message : "Network error"; + } finally { + addKeySaving = false; + } +} + async function fetchModels() { try { const res = await fetch(`${config.apiBase}/models`); @@ -112,6 +146,7 @@ onMount(() => { }} onAgentChange={(agent) => tabStore.setAgent(agent)} onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)} + onAddKey={() => { showAddKeyModal = true; addKeyId = ""; addKeyProvider = "anthropic"; addKeyError = null; }} /> </div> </div> @@ -144,3 +179,48 @@ onMount(() => { <div class="fixed top-4 right-4 z-50"> <HotReloadIndicator active={tabStore.configReloaded} /> </div> + +<!-- Add New Key Modal (page-level to escape sidebar transform) --> +{#if showAddKeyModal} + <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog"> + <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl"> + <h3 class="text-sm font-semibold">Add New Key</h3> + <div class="flex flex-col gap-1"> + <label class="text-xs text-base-content/60">Provider</label> + <select class="select select-bordered select-sm w-full" bind:value={addKeyProvider}> + <option value="anthropic">Anthropic</option> + <option value="opencode-go">OpenCode</option> + <option value="github-copilot">GitHub Copilot</option> + </select> + </div> + <div class="flex flex-col gap-1"> + <label class="text-xs text-base-content/60">Key ID</label> + <input + type="text" + class="input input-bordered input-sm w-full" + placeholder="e.g. claude-max, copilot-2" + bind:value={addKeyId} + /> + <p class="text-xs text-base-content/40">Unique identifier for this key</p> + </div> + {#if addKeyError} + <p class="text-xs text-error">{addKeyError}</p> + {/if} + <div class="flex justify-end gap-2"> + <button type="button" class="btn btn-sm btn-ghost" + onclick={() => { showAddKeyModal = false; }}> + Cancel + </button> + <button type="button" class="btn btn-sm btn-primary" + disabled={!addKeyId.trim() || addKeySaving} + onclick={addNewKey}> + {#if addKeySaving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Add Key + {/if} + </button> + </div> + </div> + </div> +{/if} diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte index 1270fcc..d6ff0f5 100644 --- a/packages/frontend/src/lib/components/ModelStatus.svelte +++ b/packages/frontend/src/lib/components/ModelStatus.svelte @@ -20,10 +20,12 @@ const { keys = [], currentModel, apiBase = "", + onAddKey = () => {}, }: { keys?: KeyInfo[]; currentModel?: string; apiBase?: string; + onAddKey?: () => void; } = $props(); const activeKeys = $derived(keys.filter((k) => k.status === "active").length); @@ -44,6 +46,9 @@ let showKeyModal = $state<string | null>(null); // keyId or null let keyModalValue = $state(""); let keyModalError = $state<string | null>(null); let keyModalSaving = $state(false); +let removingKey = $state<string | null>(null); + + async function loadCredentialStatus(): Promise<void> { try { @@ -129,6 +134,27 @@ async function saveApiKey(): Promise<void> { } } +async function removeKey(keyId: string): Promise<void> { + if (!confirm(`Remove key "${keyId}" from config?`)) return; + removingKey = keyId; + try { + const res = await fetch(`${apiBase}/models/remove-key`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: keyId }), + }); + const data = (await res.json()) as { success?: boolean; error?: string }; + if (res.ok && data.success) { + await new Promise((r) => setTimeout(r, 500)); + window.location.reload(); + } + } catch { + // ignore + } finally { + removingKey = null; + } +} + $effect(() => { void loadCredentialStatus(); void loadApiKeyStatus(); @@ -209,8 +235,21 @@ function truncate(str: string | null, max: number): string { > {key.status} </span> - <span class="text-xs font-mono">{key.id}</span> + <span class="text-xs font-mono flex-1">{key.id}</span> <span class="text-xs text-base-content/40">{key.provider}</span> + <button + type="button" + class="btn btn-xs btn-ghost btn-square text-base-content/30 hover:text-error" + disabled={removingKey === key.id} + onclick={() => removeKey(key.id)} + title="Remove key" + > + {#if removingKey === key.id} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ✕ + {/if} + </button> </div> {#if key.status === "exhausted"} <div class="pl-2 flex flex-col gap-0.5"> @@ -270,6 +309,13 @@ function truncate(str: string | null, max: number): string { </div> {/if} {/if} + <button + type="button" + class="btn btn-sm btn-primary btn-outline w-full mt-2" + onclick={onAddKey} + > + + Add New Key + </button> <!-- Import Key Modal --> {#if showKeyModal} <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog"> @@ -308,4 +354,5 @@ function truncate(str: string | null, max: number): string { </div> </div> {/if} + </div> diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 79574d2..c19fe45 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -1,4 +1,5 @@ <script lang="ts"> +import { config } from "../config.js"; import { appSettings } from "../settings.svelte.js"; import type { KeyInfo } from "../types.js"; @@ -15,6 +16,24 @@ let titleModelId = $state<string | null>(null); let availableModels = $state<string[]>([]); let loadingModels = $state(false); let autoExpandThinking = $state(appSettings.autoExpandThinking); +let backendUrl = $state(config.apiBase); +let backendUrlSaved = $state(false); + +function saveBackendUrl(): void { + const trimmed = backendUrl.trim().replace(/\/+$/, ""); + if (!trimmed) return; + config.setApiBase(trimmed); + backendUrl = trimmed; + backendUrlSaved = true; + setTimeout(() => { backendUrlSaved = false; }, 2000); +} + +function resetBackendUrl(): void { + config.setApiBase(config.defaultApiBase); + backendUrl = config.defaultApiBase; + backendUrlSaved = true; + setTimeout(() => { backendUrlSaved = false; }, 2000); +} async function loadSettings(): Promise<void> { try { @@ -143,5 +162,32 @@ $effect(() => { /> <span class="text-xs text-base-content/70">Auto-expand thinking</span> </label> + + <div class="divider my-0"></div> + + <p class="text-xs text-base-content/70">Backend URL</p> + <p class="text-xs text-base-content/40">API server address. Default: {config.defaultApiBase}</p> + <div class="flex gap-1"> + <input + type="text" + class="input input-bordered input-sm flex-1" + bind:value={backendUrl} + placeholder={config.defaultApiBase} + /> + <button type="button" class="btn btn-sm btn-primary" onclick={saveBackendUrl}> + Save + </button> + </div> + <button + type="button" + class="btn btn-xs btn-ghost btn-outline w-full" + disabled={config.apiBase === config.defaultApiBase} + onclick={resetBackendUrl} + > + Reset to default + </button> + {#if backendUrlSaved} + <p class="text-xs text-success">Saved. Reload the page to apply.</p> + {/if} </div> </div> diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 041679a..e89e351 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -25,6 +25,7 @@ const { onReasoningChange, onAgentChange = (_agent: any) => {}, onWorkingDirectoryChange = (_dir: string | null) => {}, + onAddKey = () => {}, }: { keys?: KeyInfo[]; tasks?: TaskItem[]; @@ -40,6 +41,7 @@ const { onReasoningChange: (effort: string) => void; onAgentChange?: (agent: any) => void; onWorkingDirectoryChange?: (dir: string | null) => void; + onAddKey?: () => void; } = $props(); interface Panel { @@ -129,7 +131,7 @@ function contentClass(selected: string): string { {:else if panel.selected === "Claude Reset"} <ClaudeReset {apiBase} /> {:else if panel.selected === "Model Status"} - <ModelStatus {keys} {apiBase} /> + <ModelStatus {keys} {apiBase} {onAddKey} /> {:else if panel.selected === "Tasks"} <TaskListPanel {tasks} /> {:else if panel.selected === "Config"} diff --git a/packages/frontend/src/lib/config.ts b/packages/frontend/src/lib/config.ts index c22746c..c8f4d9f 100644 --- a/packages/frontend/src/lib/config.ts +++ b/packages/frontend/src/lib/config.ts @@ -1,6 +1,34 @@ -const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3000"; +const STORAGE_KEY = "dispatch-api-url"; +const DEFAULT_API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3000"; + +function loadApiBase(): string { + if (typeof localStorage !== "undefined") { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) return saved; + } + return DEFAULT_API_BASE; +} + +let _apiBase = loadApiBase(); export const config = { - apiBase: API_BASE, - wsUrl: `${API_BASE.replace(/^http/, "ws")}/ws`, -} as const; + get apiBase() { + return _apiBase; + }, + get wsUrl() { + return `${_apiBase.replace(/^http/, "ws")}/ws`; + }, + get defaultApiBase() { + return DEFAULT_API_BASE; + }, + setApiBase(url: string) { + _apiBase = url; + if (typeof localStorage !== "undefined") { + if (url === DEFAULT_API_BASE) { + localStorage.removeItem(STORAGE_KEY); + } else { + localStorage.setItem(STORAGE_KEY, url); + } + } + }, +}; diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 57e7e28..de49d4e 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -91,10 +91,10 @@ package() { install -Dm644 dispatch.toml "${optdir}/dispatch.toml" fi - # --- systemd service --- + # --- systemd user service --- install -Dm644 \ "${srcdir}/dispatch-api.service" \ - "${pkgdir}/usr/lib/systemd/system/dispatch-api.service" + "${pkgdir}/usr/lib/systemd/user/dispatch-api.service" # --- Environment config (preserved across upgrades via backup=()) --- install -Dm644 \ @@ -119,10 +119,6 @@ package() { "${srcdir}/dispatch.svg" \ "${pkgdir}/usr/share/icons/hicolor/scalable/apps/dispatch.svg" - # --- sysusers: create the 'dispatch' system user --- - install -Dm644 /dev/null "${pkgdir}/usr/lib/sysusers.d/dispatch.conf" - echo 'u dispatch - "Dispatch API" /var/lib/dispatch' > "${pkgdir}/usr/lib/sysusers.d/dispatch.conf" - # --- License --- if [ -f "${_projectdir}/LICENSE" ]; then install -Dm644 "${_projectdir}/LICENSE" \ diff --git a/packaging/dispatch-api.service b/packaging/dispatch-api.service index ccbb727..120d460 100644 --- a/packaging/dispatch-api.service +++ b/packaging/dispatch-api.service @@ -1,12 +1,8 @@ [Unit] Description=Dispatch API Backend -After=network-online.target -Wants=network-online.target [Service] Type=simple -User=dispatch -Group=dispatch WorkingDirectory=/opt/dispatch ExecStart=/usr/bin/bun packages/api/src/index.ts EnvironmentFile=-/etc/dispatch/dispatch-api.conf @@ -15,19 +11,5 @@ RestartSec=5 StandardOutput=journal StandardError=journal -# Data directory: systemd creates /var/lib/dispatch owned by dispatch:dispatch -StateDirectory=dispatch - -# Tell the app to store its SQLite DB under /var/lib/dispatch/ -Environment=XDG_DATA_HOME=/var/lib - -# Security hardening -ProtectSystem=strict -ProtectHome=true -NoNewPrivileges=true -PrivateTmp=true -PrivateDevices=true -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX - [Install] -WantedBy=multi-user.target +WantedBy=default.target diff --git a/packaging/dispatch.install b/packaging/dispatch.install index e6db694..7d1439f 100644 --- a/packaging/dispatch.install +++ b/packaging/dispatch.install @@ -3,7 +3,7 @@ post_install() { echo "==> Dispatch has been installed." echo " Edit /etc/dispatch/dispatch-api.conf to configure the API." echo " Then enable and start the service:" - echo " systemctl enable --now dispatch-api" + echo " systemctl --user enable --now dispatch-api" echo "" } @@ -11,6 +11,6 @@ post_upgrade() { echo "" echo "==> Dispatch has been upgraded." echo " You may need to restart the service:" - echo " systemctl restart dispatch-api" + echo " systemctl --user restart dispatch-api" echo "" } |
