feat: add Sparrow frontend scaffold (Vue 3 + Vite + TypeScript)

Stack: Vue 3 + Pinia + WaveSurfer.js + TypeScript, proxied to FastAPI on :8513

Components
- ChainSidebar: chain list, create/delete, active chain highlight
- ChainTree: spine row (committed nodes) + branch row, upload drop zone, export buttons
- NodeCard: status dot + label, duration, commit/discard actions, generating spinner
- BranchPanel: WaveSurfer waveform + branch form (prompt, duration, cfg, prompt window)
- WavePlayer: WaveSurfer.js waveform with play/pause and time display

State & SSE
- Pinia chain store: REST for tree, SSE patch for live node status updates
- useNodeSSE composable: EventSource per active chain, auto-reconnects on error
- applyStatusEvent(): merges node-status SSE events into store without full refetch

UX
- Dark theme by default, light theme via prefers-color-scheme
- CSS custom properties throughout for easy theming
- Responsive: sidebar + main split, compact at <640px

manage.sh updated: start/stop both API (:8513) and frontend (:8514) together
This commit is contained in:
pyr0ball 2026-04-17 15:35:03 -07:00
parent a6f60c9e07
commit 3c30cbe40d
27 changed files with 2680 additions and 18 deletions

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
frontend/README.md Normal file
View file

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1434
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
frontend/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.2.1",
"pinia": "^3.0.4",
"vue": "^3.5.32",
"wavesurfer.js": "^7.12.6"
},
"devDependencies": {
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"typescript": "~6.0.2",
"vite": "^8.0.4",
"vue-tsc": "^3.2.6"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

42
frontend/src/App.vue Normal file
View file

@ -0,0 +1,42 @@
<template>
<div class="app-layout">
<header class="app-header">
<span class="app-logo">Sparrow</span>
<span class="app-tagline">AI music continuation</span>
</header>
<div class="app-body">
<ChainSidebar />
<main class="app-main">
<ChainTree />
<BranchPanel />
</main>
</div>
<div
v-if="store.error"
class="app-toast app-toast--error"
role="alert"
@click="store.error = null"
>
{{ store.error }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import BranchPanel from './components/BranchPanel.vue'
import ChainSidebar from './components/ChainSidebar.vue'
import ChainTree from './components/ChainTree.vue'
import { useNodeSSE } from './composables/useNodeSSE'
import { useChainStore } from './stores/chain'
const store = useChainStore()
const chainId = computed(() => store.activeChain?.id ?? null)
// Subscribe to SSE for the active chain
useNodeSSE(chainId)
onMounted(() => store.loadChains())
</script>

72
frontend/src/api/index.ts Normal file
View file

@ -0,0 +1,72 @@
// src/api/index.ts — typed fetch helpers for the Sparrow backend
import type { BranchRequest, ChainRow, ChainTree, NodeRow } from '../types'
const BASE = '/api'
async function request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
})
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(detail?.detail ?? res.statusText)
}
if (res.status === 204) return undefined as T
return res.json()
}
// ── Chains ────────────────────────────────────────────────────────────────────
export const api = {
chains: {
list: () => request<ChainRow[]>('GET', '/chains/'),
create: (name: string) => request<ChainRow>('POST', '/chains/', { name }),
get: (id: string) => request<ChainTree>('GET', `/chains/${id}`),
delete: (id: string) => request<void>('DELETE', `/chains/${id}`),
upload: (chainId: string, file: File): Promise<NodeRow> => {
const fd = new FormData()
fd.append('file', file)
return fetch(`${BASE}/chains/${chainId}/upload`, {
method: 'POST',
body: fd,
}).then(async (res) => {
if (!res.ok) {
const d = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(d?.detail ?? res.statusText)
}
return res.json()
})
},
export: (chainId: string, fmt: 'wav' | 'mp3' = 'wav'): Promise<Blob> =>
fetch(`${BASE}/chains/${chainId}/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ format: fmt }),
}).then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.blob()
}),
},
nodes: {
branch: (parentId: string, req: BranchRequest) =>
request<NodeRow>('POST', `/nodes/${parentId}/branch`, req),
commit: (nodeId: string) =>
request<NodeRow>('POST', `/nodes/${nodeId}/commit`),
delete: (nodeId: string) => request<void>('DELETE', `/nodes/${nodeId}`),
audioUrl: (nodeId: string) => `${BASE}/nodes/${nodeId}/audio`,
},
gpu: {
status: () => request<Record<string, unknown>>('GET', '/gpu/status'),
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,118 @@
<template>
<div class="branch-panel">
<!-- Waveform for selected ready node -->
<WavePlayer
v-if="node && node.status === 'ready' && node.audio_path"
:src="audioUrl"
class="branch-panel-wave"
/>
<!-- Branch form -->
<form v-if="node && node.status === 'ready'" class="branch-form" @submit.prevent="onBranch">
<h3 class="branch-form-title">New branch from "{{ promptPreview }}"</h3>
<label class="form-label">
Prompt
<textarea
v-model="form.prompt"
class="input input-textarea"
placeholder="Describe the continuation (e.g. 'upbeat jazz, walking bass')"
rows="2"
maxlength="500"
/>
</label>
<div class="form-row">
<label class="form-label">
Duration (s)
<input v-model.number="form.duration_s" type="number" class="input" min="5" max="120" step="5" />
</label>
<label class="form-label">
CFG scale
<input v-model.number="form.cfg_coef" type="number" class="input" min="1" max="10" step="0.5" />
</label>
<label class="form-label">
Prompt window (s)
<input v-model.number="form.prompt_duration_s" type="number" class="input" min="1" max="30" step="1" />
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="generating">
{{ generating ? 'Generating…' : 'Generate branch' }}
</button>
</div>
<p v-if="formError" class="form-error">{{ formError }}</p>
</form>
<!-- Upload prompt when no root yet -->
<div v-else-if="!node && activeChain && activeChain.nodes.length === 0" class="upload-prompt">
<p>Upload source audio to start a chain.</p>
<label class="btn btn-primary upload-label">
Choose audio file
<input type="file" accept="audio/*" class="sr-only" @change="onUpload" />
</label>
<p v-if="uploadError" class="form-error">{{ uploadError }}</p>
</div>
<div v-else-if="!node" class="panel-placeholder">
Select a node to play, branch, or commit.
</div>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { api } from '../api'
import { useChainStore } from '../stores/chain'
import WavePlayer from './WavePlayer.vue'
const store = useChainStore()
const generating = ref(false)
const formError = ref<string | null>(null)
const uploadError = ref<string | null>(null)
const activeChain = computed(() => store.activeChain)
const node = computed(() =>
store.selectedNodeId ? store.getNode(store.selectedNodeId) : null,
)
const audioUrl = computed(() =>
node.value ? api.nodes.audioUrl(node.value.id) : '',
)
const promptPreview = computed(() =>
node.value?.prompt ? node.value.prompt.slice(0, 40) : 'source',
)
const form = reactive({
prompt: '',
duration_s: 15,
cfg_coef: 3.0,
prompt_duration_s: 10,
})
async function onBranch() {
if (!node.value) return
generating.value = true
formError.value = null
try {
await store.branchNode(node.value.id, { ...form })
} catch (e: unknown) {
formError.value = e instanceof Error ? e.message : 'Generation failed'
} finally {
generating.value = false
}
}
async function onUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !activeChain.value) return
uploadError.value = null
try {
await store.uploadRoot(activeChain.value.id, file)
} catch (err: unknown) {
uploadError.value = err instanceof Error ? err.message : 'Upload failed'
}
}
</script>

View file

@ -0,0 +1,78 @@
<template>
<aside class="sidebar">
<div class="sidebar-header">
<h2 class="sidebar-title">Chains</h2>
<button class="btn-icon" title="New chain" @click="showCreate = true">+</button>
</div>
<div v-if="showCreate" class="create-form">
<input
v-model="newName"
class="input"
placeholder="Chain name"
maxlength="80"
@keydown.enter="onCreate"
@keydown.esc="showCreate = false"
ref="nameInput"
/>
<div class="create-actions">
<button class="btn btn-primary btn-sm" @click="onCreate" :disabled="!newName.trim()">
Create
</button>
<button class="btn btn-ghost btn-sm" @click="showCreate = false">Cancel</button>
</div>
</div>
<ul class="chain-list" v-if="store.chains.length">
<li
v-for="chain in store.chains"
:key="chain.id"
class="chain-item"
:class="{ active: store.activeChain?.id === chain.id }"
@click="store.loadChain(chain.id)"
>
<span class="chain-name">{{ chain.name }}</span>
<span class="chain-meta">{{ chain.node_count }} nodes</span>
<button
class="btn-icon chain-delete"
title="Delete chain"
@click.stop="onDelete(chain.id)"
>
×
</button>
</li>
</ul>
<p v-else class="sidebar-empty">No chains yet.</p>
</aside>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { useChainStore } from '../stores/chain'
const store = useChainStore()
const showCreate = ref(false)
const newName = ref('')
const nameInput = ref<HTMLInputElement | null>(null)
watch(showCreate, async (v) => {
if (v) {
newName.value = ''
await nextTick()
nameInput.value?.focus()
}
})
async function onCreate() {
const name = newName.value.trim()
if (!name) return
await store.createChain(name)
showCreate.value = false
}
async function onDelete(id: string) {
if (!confirm('Delete this chain and all its nodes?')) return
await store.deleteChain(id)
await store.loadChains()
}
</script>

View file

@ -0,0 +1,99 @@
<template>
<div class="chain-tree" v-if="store.activeChain">
<div class="chain-tree-header">
<h2 class="chain-tree-name">{{ store.activeChain.name }}</h2>
<div class="chain-tree-actions">
<button class="btn btn-ghost btn-sm" @click="onExport('wav')">Export WAV</button>
<button class="btn btn-ghost btn-sm" @click="onExport('mp3')">Export MP3</button>
<span v-if="gpuStatus" class="gpu-badge" :class="gpuOk ? 'gpu-ok' : 'gpu-na'">
GPU {{ gpuOk ? 'ready' : 'n/a' }}
</span>
</div>
</div>
<!-- Upload area if no nodes yet -->
<div v-if="store.activeChain.nodes.length === 0" class="tree-empty">
<label class="upload-drop">
<div class="upload-drop-text">Drop audio here or click to upload</div>
<input type="file" accept="audio/*" class="sr-only" @change="onUpload" />
</label>
</div>
<!-- Node timeline -->
<div v-else class="node-timeline">
<!-- Group by depth level: committed spine first, then branches -->
<div class="spine-row">
<NodeCard
v-for="node in spineNodes"
:key="node.id"
:node="node"
class="spine-node"
/>
</div>
<div v-if="branchNodes.length" class="branch-row">
<NodeCard
v-for="node in branchNodes"
:key="node.id"
:node="node"
class="branch-node"
/>
</div>
</div>
<p v-if="exportError" class="form-error">{{ exportError }}</p>
</div>
<div v-else class="tree-placeholder">
<p>Select or create a chain to start.</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api } from '../api'
import { useChainStore } from '../stores/chain'
import NodeCard from './NodeCard.vue'
const store = useChainStore()
const exportError = ref<string | null>(null)
const gpuStatus = ref<Record<string, unknown> | null>(null)
const spineNodes = computed(() =>
store.activeChain?.nodes.filter((n) => n.is_committed) ?? [],
)
const branchNodes = computed(() =>
store.activeChain?.nodes.filter((n) => !n.is_committed) ?? [],
)
const gpuOk = computed(() => gpuStatus.value && !gpuStatus.value['mock'])
onMounted(async () => {
try {
gpuStatus.value = await api.gpu.status()
} catch {
gpuStatus.value = null
}
})
async function onExport(fmt: 'wav' | 'mp3') {
if (!store.activeChain) return
exportError.value = null
try {
const blob = await api.chains.export(store.activeChain.id, fmt)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${store.activeChain.name}.${fmt}`
a.click()
URL.revokeObjectURL(url)
} catch (e: unknown) {
exportError.value = e instanceof Error ? e.message : 'Export failed'
}
}
async function onUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !store.activeChain) return
await store.uploadRoot(store.activeChain.id, file)
}
</script>

View file

@ -0,0 +1,78 @@
<template>
<div
class="node-card"
:class="[`node-card--${node.status}`, { 'node-card--selected': isSelected, 'node-card--committed': node.is_committed }]"
@click="store.selectNode(node.id)"
>
<!-- Status indicator -->
<div class="node-status-bar">
<span class="node-status-dot" :class="`dot--${node.status}`" />
<span class="node-status-label">{{ STATUS_LABELS[node.status] }}</span>
<span v-if="node.duration_s" class="node-duration">{{ fmt(node.duration_s) }}</span>
<span v-if="node.is_committed" class="node-committed-badge">spine</span>
</div>
<!-- Prompt preview (non-root nodes) -->
<p v-if="node.prompt" class="node-prompt">{{ node.prompt }}</p>
<p v-else class="node-prompt node-prompt--root">Source audio</p>
<!-- Error message -->
<p v-if="node.status === 'error' && node.error_msg" class="node-error">
{{ node.error_msg }}
</p>
<!-- Actions (shown when selected and ready) -->
<div v-if="isSelected && node.status === 'ready'" class="node-actions">
<button
v-if="!node.is_committed && node.parent_id"
class="btn btn-primary btn-sm"
@click.stop="onCommit"
>
Commit
</button>
<button
v-if="!node.is_committed && node.parent_id"
class="btn btn-ghost btn-sm"
@click.stop="onDiscard"
>
Discard
</button>
</div>
<!-- Generating spinner -->
<div v-if="node.status === 'generating'" class="node-spinner" aria-label="Generating" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useChainStore } from '../stores/chain'
import type { NodeRow } from '../types'
const props = defineProps<{ node: NodeRow }>()
const store = useChainStore()
const isSelected = computed(() => store.selectedNodeId === props.node.id)
const STATUS_LABELS: Record<string, string> = {
pending: 'Pending',
generating: 'Generating',
ready: 'Ready',
error: 'Error',
}
function fmt(s: number): string {
const m = Math.floor(s / 60)
const sec = Math.round(s % 60)
return m > 0 ? `${m}m ${sec}s` : `${sec}s`
}
async function onCommit() {
await store.commitNode(props.node.id)
}
async function onDiscard() {
if (!confirm('Discard this branch?')) return
await store.discardNode(props.node.id)
}
</script>

View file

@ -0,0 +1,75 @@
<template>
<div class="wave-player">
<div ref="waveEl" class="wave-container" />
<div class="wave-controls">
<button class="btn btn-primary btn-sm" @click="togglePlay" :disabled="!ready">
{{ playing ? 'Pause' : 'Play' }}
</button>
<span class="wave-time">{{ currentTime }} / {{ totalTime }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import WaveSurfer from 'wavesurfer.js'
const props = defineProps<{ src: string }>()
const waveEl = ref<HTMLElement | null>(null)
const playing = ref(false)
const ready = ref(false)
const currentTime = ref('0:00')
const totalTime = ref('0:00')
let ws: WaveSurfer | null = null
function fmt(s: number): string {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${String(sec).padStart(2, '0')}`
}
function initWaveSurfer(src: string) {
ws?.destroy()
playing.value = false
ready.value = false
currentTime.value = '0:00'
totalTime.value = '0:00'
ws = WaveSurfer.create({
container: waveEl.value!,
waveColor: 'var(--color-wave)',
progressColor: 'var(--color-wave-progress)',
height: 64,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
url: src,
})
ws.on('ready', () => {
ready.value = true
totalTime.value = fmt(ws!.getDuration())
})
ws.on('timeupdate', (t) => {
currentTime.value = fmt(t)
})
ws.on('finish', () => {
playing.value = false
})
}
function togglePlay() {
if (!ws) return
ws.playPause()
playing.value = !playing.value
}
onMounted(() => initWaveSurfer(props.src))
watch(() => props.src, (src) => initWaveSurfer(src))
onBeforeUnmount(() => ws?.destroy())
</script>

View file

@ -0,0 +1,44 @@
// src/composables/useNodeSSE.ts — SSE subscription for chain node-status events
import { onUnmounted, watch } from 'vue'
import type { Ref } from 'vue'
import { useChainStore } from '../stores/chain'
import type { NodeStatusEvent } from '../types'
export function useNodeSSE(chainId: Ref<string | null>) {
const store = useChainStore()
let es: EventSource | null = null
function connect(id: string) {
disconnect()
es = new EventSource(`/api/chains/${id}/events`)
es.addEventListener('node-status', (e: MessageEvent) => {
try {
const event: NodeStatusEvent = JSON.parse(e.data)
store.applyStatusEvent(event)
} catch {
// malformed event — ignore
}
})
es.onerror = () => {
// Browser will auto-reconnect on error; no action needed
}
}
function disconnect() {
es?.close()
es = null
}
watch(
chainId,
(id) => {
if (id) connect(id)
else disconnect()
},
{ immediate: true },
)
onUnmounted(disconnect)
return { disconnect }
}

6
frontend/src/main.ts Normal file
View file

@ -0,0 +1,6 @@
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).use(createPinia()).mount('#app')

View file

@ -0,0 +1,145 @@
// src/stores/chain.ts — Pinia store for chain tree + live node status
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../api'
import type { ChainRow, ChainTree, NodeRow, NodeStatusEvent } from '../types'
export const useChainStore = defineStore('chain', () => {
const chains = ref<ChainRow[]>([])
const activeChain = ref<ChainTree | null>(null)
const selectedNodeId = ref<string | null>(null)
const error = ref<string | null>(null)
const loading = ref(false)
// ── Chain list ───────────────────────────────────────────────────────────
async function loadChains() {
loading.value = true
error.value = null
try {
chains.value = await api.chains.list()
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load chains'
} finally {
loading.value = false
}
}
async function createChain(name: string) {
const chain = await api.chains.create(name)
chains.value = [chain, ...chains.value]
return chain
}
async function deleteChain(id: string) {
await api.chains.delete(id)
chains.value = chains.value.filter((c) => c.id !== id)
if (activeChain.value?.id === id) {
activeChain.value = null
selectedNodeId.value = null
}
}
// ── Active chain + tree ──────────────────────────────────────────────────
async function loadChain(id: string) {
loading.value = true
error.value = null
try {
activeChain.value = await api.chains.get(id)
selectedNodeId.value = null
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to load chain'
} finally {
loading.value = false
}
}
async function uploadRoot(chainId: string, file: File): Promise<NodeRow> {
const node = await api.chains.upload(chainId, file)
await loadChain(chainId)
return node
}
// ── Node actions ─────────────────────────────────────────────────────────
async function branchNode(
parentId: string,
params: { prompt: string; duration_s: number; cfg_coef: number; prompt_duration_s: number }
): Promise<NodeRow> {
const node = await api.nodes.branch(parentId, params)
// Insert pending node into tree immediately (no refetch needed until SSE confirms ready)
if (activeChain.value) {
activeChain.value = {
...activeChain.value,
nodes: [...activeChain.value.nodes, node],
}
}
return node
}
async function commitNode(nodeId: string) {
const updated = await api.nodes.commit(nodeId)
// Refresh full tree after commit (siblings are deleted server-side)
if (activeChain.value) await loadChain(activeChain.value.id)
return updated
}
async function discardNode(nodeId: string) {
await api.nodes.delete(nodeId)
if (activeChain.value) {
activeChain.value = {
...activeChain.value,
nodes: activeChain.value.nodes.filter((n) => n.id !== nodeId),
}
}
if (selectedNodeId.value === nodeId) selectedNodeId.value = null
}
// ── SSE patch (called by useNodeSSE composable) ──────────────────────────
function applyStatusEvent(event: NodeStatusEvent) {
if (!activeChain.value) return
activeChain.value = {
...activeChain.value,
nodes: activeChain.value.nodes.map((n) =>
n.id === event.node_id
? {
...n,
status: event.status,
audio_path: event.audio_path ?? n.audio_path,
duration_s: event.duration_s ?? n.duration_s,
error_msg: event.error_msg ?? n.error_msg,
}
: n
),
}
}
function selectNode(id: string | null) {
selectedNodeId.value = id
}
function getNode(id: string): NodeRow | undefined {
return activeChain.value?.nodes.find((n) => n.id === id)
}
return {
chains,
activeChain,
selectedNodeId,
error,
loading,
loadChains,
createChain,
deleteChain,
loadChain,
uploadRoot,
branchNode,
commitNode,
discardNode,
applyStatusEvent,
selectNode,
getNode,
}
})

209
frontend/src/style.css Normal file
View file

@ -0,0 +1,209 @@
/* Sparrow — theme-aware, responsive CSS */
/* ── Design tokens ───────────────────────────────────────────────────────── */
:root {
--color-bg: #0f0f11;
--color-surface: #1a1a1f;
--color-surface-2: #25252d;
--color-border: #2e2e3a;
--color-text: #e4e4f0;
--color-text-muted: #7a7a96;
--color-primary: #7c5cbf;
--color-primary-hover: #9370e0;
--color-accent: #4fc3a1;
--color-error: #e05c6b;
--color-wave: #7c5cbf;
--color-wave-progress: #4fc3a1;
--color-committed: #1e2e24;
--color-pending: #2a2a1e;
--color-generating: #1e2a2a;
--sidebar-width: 220px;
--panel-height: 260px;
--radius: 8px;
--radius-sm: 4px;
font-size: 14px;
}
@media (prefers-color-scheme: light) {
:root {
--color-bg: #f5f5f8;
--color-surface: #ffffff;
--color-surface-2: #ededf2;
--color-border: #d0d0e0;
--color-text: #1a1a2e;
--color-text-muted: #6060a0;
--color-committed: #d4f0e4;
--color-pending: #faf0d0;
--color-generating: #d4ecf0;
}
}
/* ── Reset ───────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--color-bg); color: var(--color-text); font-family: system-ui, -apple-system, sans-serif; }
button { cursor: pointer; font: inherit; }
ul { list-style: none; }
.sr-only { position: absolute; width: 1px; height: 1px; clip: rect(0,0,0,0); overflow: hidden; }
/* ── Layout ──────────────────────────────────────────────────────────────── */
.app-layout { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; }
.app-header {
display: flex; align-items: center; gap: 12px;
padding: 0 20px; height: 48px;
background: var(--color-surface); border-bottom: 1px solid var(--color-border); flex-shrink: 0;
}
.app-logo { font-size: 1.1rem; font-weight: 700; color: var(--color-primary); letter-spacing: .02em; }
.app-tagline { font-size: .8rem; color: var(--color-text-muted); }
.app-body { display: flex; flex: 1; overflow: hidden; }
.app-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
.sidebar {
width: var(--sidebar-width); background: var(--color-surface);
border-right: 1px solid var(--color-border); display: flex; flex-direction: column; overflow: hidden;
}
.sidebar-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 14px; border-bottom: 1px solid var(--color-border);
}
.sidebar-title { font-size: .85rem; font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: .06em; }
.chain-list { flex: 1; overflow-y: auto; padding: 6px 0; }
.chain-item {
display: flex; align-items: center; gap: 6px; padding: 8px 14px; cursor: pointer;
border-left: 3px solid transparent; transition: background .12s;
}
.chain-item:hover { background: var(--color-surface-2); }
.chain-item.active { background: var(--color-surface-2); border-left-color: var(--color-primary); }
.chain-name { flex: 1; font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chain-meta { font-size: .75rem; color: var(--color-text-muted); }
.chain-delete { opacity: 0; color: var(--color-error); font-size: 1rem; line-height: 1; }
.chain-item:hover .chain-delete { opacity: 1; }
.sidebar-empty { padding: 16px 14px; color: var(--color-text-muted); font-size: .85rem; }
.create-form { padding: 10px 14px; border-bottom: 1px solid var(--color-border); }
.create-actions { display: flex; gap: 6px; margin-top: 6px; }
/* ── Chain tree ──────────────────────────────────────────────────────────── */
.chain-tree { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.chain-tree-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 18px; border-bottom: 1px solid var(--color-border); flex-shrink: 0;
}
.chain-tree-name { font-size: 1rem; font-weight: 600; }
.chain-tree-actions { display: flex; align-items: center; gap: 8px; }
.tree-empty, .tree-placeholder {
flex: 1; display: flex; align-items: center; justify-content: center; color: var(--color-text-muted);
}
.upload-drop {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; width: 360px; height: 180px; border: 2px dashed var(--color-border);
border-radius: var(--radius); cursor: pointer; transition: border-color .15s;
}
.upload-drop:hover { border-color: var(--color-primary); }
.upload-drop-text { color: var(--color-text-muted); font-size: .9rem; }
.node-timeline { flex: 1; overflow: auto; padding: 18px; }
.spine-row { display: flex; gap: 12px; margin-bottom: 12px; }
.branch-row {
display: flex; gap: 12px; flex-wrap: wrap;
padding-left: 20px; border-left: 2px solid var(--color-border); margin-left: 20px;
}
/* ── Node card ───────────────────────────────────────────────────────────── */
.node-card {
min-width: 160px; max-width: 220px; padding: 10px 12px;
border-radius: var(--radius); border: 1.5px solid var(--color-border);
background: var(--color-surface); cursor: pointer; position: relative;
transition: border-color .12s, box-shadow .12s;
}
.node-card:hover { border-color: var(--color-primary); }
.node-card--selected { border-color: var(--color-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 30%, transparent); }
.node-card--committed { background: var(--color-committed); }
.node-card--pending { background: var(--color-pending); }
.node-card--generating { background: var(--color-generating); }
.node-status-bar { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.node-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot--ready { background: var(--color-accent); }
.dot--pending { background: #a0902a; }
.dot--generating { background: var(--color-primary); animation: pulse 1s ease-in-out infinite; }
.dot--error { background: var(--color-error); }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
.node-status-label { font-size: .7rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: .04em; }
.node-duration { font-size: .75rem; color: var(--color-text-muted); margin-left: auto; }
.node-committed-badge { font-size: .65rem; background: var(--color-accent); color: #0a1a12; padding: 1px 5px; border-radius: 20px; font-weight: 600; }
.node-prompt { font-size: .85rem; color: var(--color-text); margin: 4px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.node-prompt--root { color: var(--color-text-muted); font-style: italic; }
.node-error { font-size: .75rem; color: var(--color-error); margin-top: 4px; }
.node-actions { display: flex; gap: 6px; margin-top: 8px; }
.node-spinner {
width: 16px; height: 16px; border: 2px solid var(--color-border); border-top-color: var(--color-primary);
border-radius: 50%; animation: spin .8s linear infinite; position: absolute; top: 10px; right: 10px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Branch panel ────────────────────────────────────────────────────────── */
.branch-panel {
height: var(--panel-height); flex-shrink: 0;
border-top: 1px solid var(--color-border); background: var(--color-surface);
display: flex; overflow: hidden;
}
.branch-panel-wave { width: 260px; border-right: 1px solid var(--color-border); padding: 12px; flex-shrink: 0; }
.branch-form { flex: 1; padding: 14px 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; }
.branch-form-title { font-size: .9rem; font-weight: 600; color: var(--color-text-muted); }
.form-row { display: flex; gap: 10px; flex-wrap: wrap; }
.form-label { display: flex; flex-direction: column; gap: 4px; font-size: .8rem; color: var(--color-text-muted); }
.form-actions { margin-top: 2px; }
.form-error { font-size: .8rem; color: var(--color-error); }
.panel-placeholder, .upload-prompt {
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 10px; color: var(--color-text-muted); font-size: .9rem;
}
.upload-label { cursor: pointer; }
/* ── Wave player ─────────────────────────────────────────────────────────── */
.wave-player { display: flex; flex-direction: column; gap: 8px; }
.wave-container { border-radius: var(--radius-sm); overflow: hidden; background: var(--color-surface-2); }
.wave-controls { display: flex; align-items: center; gap: 10px; }
.wave-time { font-size: .75rem; color: var(--color-text-muted); font-variant-numeric: tabular-nums; }
/* ── Buttons ─────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 6px 14px; border-radius: var(--radius-sm); border: 1px solid transparent;
font-size: .85rem; font-weight: 500; transition: background .12s, opacity .12s;
}
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn-primary { background: var(--color-primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
.btn-ghost { background: transparent; border-color: var(--color-border); color: var(--color-text); }
.btn-ghost:hover:not(:disabled) { background: var(--color-surface-2); }
.btn-sm { padding: 4px 10px; font-size: .78rem; }
.btn-icon { background: none; border: none; color: var(--color-text-muted); font-size: 1.2rem; line-height: 1; padding: 2px 6px; border-radius: var(--radius-sm); }
.btn-icon:hover { color: var(--color-text); background: var(--color-surface-2); }
/* ── Inputs ──────────────────────────────────────────────────────────────── */
.input {
padding: 6px 10px; border: 1px solid var(--color-border); border-radius: var(--radius-sm);
background: var(--color-surface-2); color: var(--color-text); font: inherit; width: 100%;
}
.input:focus { outline: none; border-color: var(--color-primary); }
.input-textarea { resize: vertical; min-height: 52px; }
input[type="number"].input { width: 90px; }
/* ── GPU badge ───────────────────────────────────────────────────────────── */
.gpu-badge { font-size: .72rem; padding: 2px 8px; border-radius: 20px; font-weight: 600; }
.gpu-ok { background: var(--color-accent); color: #0a1a12; }
.gpu-na { background: var(--color-surface-2); color: var(--color-text-muted); border: 1px solid var(--color-border); }
/* ── Toast ───────────────────────────────────────────────────────────────── */
.app-toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
padding: 10px 20px; border-radius: var(--radius); font-size: .85rem; cursor: pointer; z-index: 100;
}
.app-toast--error { background: var(--color-error); color: #fff; }
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
:root { --sidebar-width: 180px; --panel-height: 220px; }
.chain-tree-actions { display: none; }
.form-row { flex-direction: column; }
}

54
frontend/src/types.ts Normal file
View file

@ -0,0 +1,54 @@
// src/types.ts — shared domain types mirroring the backend Pydantic schemas
export type NodeStatus = 'pending' | 'generating' | 'ready' | 'error'
export interface NodeRow {
id: string
chain_id: string
parent_id: string | null
audio_path: string | null
duration_s: number | null
status: NodeStatus
is_committed: boolean
prompt: string
energy: number | null
tempo_feel: number | null
density: number | null
cfg_coef: number
prompt_duration_s: number
error_msg: string | null
created_at: number
children: NodeRow[]
}
export interface ChainRow {
id: string
name: string
created_at: number
node_count: number
}
export interface ChainTree {
id: string
name: string
created_at: number
nodes: NodeRow[]
}
export interface BranchRequest {
prompt?: string
energy?: number | null
tempo_feel?: number | null
density?: number | null
cfg_coef?: number
prompt_duration_s?: number
duration_s?: number
}
export interface NodeStatusEvent {
node_id: string
status: NodeStatus
audio_path?: string
duration_s?: number
error_msg?: string
}

View file

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

15
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 8514,
proxy: {
'/api': {
target: 'http://localhost:8513',
changeOrigin: true,
},
},
},
})

View file

@ -4,44 +4,96 @@ set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="$REPO_DIR/.env"
PID_FILE="$REPO_DIR/data/sparrow.pid"
FE_PID_FILE="$REPO_DIR/data/sparrow-fe.pid"
LOG_FILE="$REPO_DIR/data/sparrow.log"
FE_LOG_FILE="$REPO_DIR/data/sparrow-fe.log"
[[ -f "$ENV_FILE" ]] && source "$ENV_FILE"
[[ -f "$ENV_FILE" ]] && { set -a; source "$ENV_FILE"; set +a; }
SPARROW_PORT="${SPARROW_PORT:-8513}"
SPARROW_FE_PORT="${SPARROW_FE_PORT:-8514}"
cmd="${1:-help}"
start() {
_running() { [[ -f "$1" ]] && kill -0 "$(cat "$1")" 2>/dev/null; }
start_api() {
mkdir -p "$REPO_DIR/data"
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "sparrow already running (PID $(cat "$PID_FILE"))"
if _running "$PID_FILE"; then
echo "API already running (PID $(cat "$PID_FILE"))"
return
fi
echo "Starting sparrow on port $SPARROW_PORT..."
echo "Starting Sparrow API on port $SPARROW_PORT..."
conda run -n cf \
uvicorn app.main:app --host "${SPARROW_HOST:-0.0.0.0}" --port "$SPARROW_PORT" \
>> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
echo "Started (PID $!). Logs: $LOG_FILE"
echo "API started (PID $!). Logs: $LOG_FILE"
}
stop() {
stop_api() {
if [[ -f "$PID_FILE" ]]; then
kill "$(cat "$PID_FILE")" 2>/dev/null && echo "Stopped." || echo "Not running."
kill "$(cat "$PID_FILE")" 2>/dev/null && echo "API stopped." || echo "API not running."
rm -f "$PID_FILE"
else
echo "Not running."
echo "API not running."
fi
}
start_fe() {
mkdir -p "$REPO_DIR/data"
if _running "$FE_PID_FILE"; then
echo "Frontend already running (PID $(cat "$FE_PID_FILE"))"
return
fi
echo "Starting Sparrow frontend on port $SPARROW_FE_PORT..."
(cd "$REPO_DIR/frontend" && npm run dev -- --port "$SPARROW_FE_PORT" --host 0.0.0.0) \
>> "$FE_LOG_FILE" 2>&1 &
echo $! > "$FE_PID_FILE"
echo "Frontend started (PID $!). Logs: $FE_LOG_FILE"
}
stop_fe() {
if [[ -f "$FE_PID_FILE" ]]; then
kill "$(cat "$FE_PID_FILE")" 2>/dev/null && echo "Frontend stopped." || echo "Frontend not running."
rm -f "$FE_PID_FILE"
else
echo "Frontend not running."
fi
}
case "$cmd" in
start) start ;;
stop) stop ;;
restart) stop; sleep 1; start ;;
status) [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null \
&& echo "running (PID $(cat "$PID_FILE"))" || echo "stopped" ;;
logs) tail -f "$LOG_FILE" ;;
open) xdg-open "http://localhost:$SPARROW_PORT" 2>/dev/null || \
open "http://localhost:$SPARROW_PORT" 2>/dev/null || true ;;
*) echo "Usage: $0 {start|stop|restart|status|logs|open}" ;;
start)
start_api
start_fe
;;
stop)
stop_api
stop_fe
;;
restart)
stop_api; stop_fe; sleep 1; start_api; start_fe
;;
start-api) start_api ;;
stop-api) stop_api ;;
start-fe) start_fe ;;
stop-fe) stop_fe ;;
status)
_running "$PID_FILE" && echo "API: running (PID $(cat "$PID_FILE"))" || echo "API: stopped"
_running "$FE_PID_FILE" && echo "Frontend: running (PID $(cat "$FE_PID_FILE"))" || echo "Frontend: stopped"
;;
logs)
echo "=== API ===" && tail -40 "$LOG_FILE" 2>/dev/null || true
echo "=== Frontend ===" && tail -20 "$FE_LOG_FILE" 2>/dev/null || true
;;
open)
xdg-open "http://localhost:$SPARROW_FE_PORT" 2>/dev/null || \
open "http://localhost:$SPARROW_FE_PORT" 2>/dev/null || true
;;
test)
cd "$REPO_DIR" && conda run -n cf python -m pytest tests/ -v "$@"
;;
*)
echo "Usage: $0 {start|stop|restart|status|logs|open|test}"
echo " $0 {start-api|stop-api|start-fe|stop-fe}"
;;
esac