feat: offer a demo instance link from the live deployment
Some checks are pending
ci / backend (push) Waiting to run
ci / frontend (push) Waiting to run

This commit is contained in:
Justin Visser 2026-08-11 10:21:18 +02:00
parent cdace7d170
commit b60a492417
10 changed files with 63 additions and 8 deletions

View file

@ -18,6 +18,9 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_mode: AppMode = AppMode.DEMO
# Optional public URL of a fixture-replay twin of this deployment; the UI
# offers it as an escape hatch when the live Spotify quota runs out.
demo_instance_url: str = ""
# Spotify application and OAuth. The redirect URI must exactly match a
# URI registered in the Spotify developer dashboard, port included.

View file

@ -124,7 +124,10 @@ def create_app(
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok", "mode": active_settings.app_mode}
payload = {"status": "ok", "mode": active_settings.app_mode}
if active_settings.demo_instance_url:
payload["demo_url"] = active_settings.demo_instance_url
return payload
app.include_router(router)
app.include_router(recommendations_router)

View file

@ -8,6 +8,7 @@ const props = defineProps<{
auth: AuthState
mode: AppMode | null
healthFailed: boolean
demoUrl: string | null
}>()
const emit = defineEmits<{ logout: []; reset: []; toggleDev: [opener: HTMLElement] }>()
@ -47,6 +48,9 @@ const modeLabel = computed(() => {
<span class="mode" :class="{ failed: healthFailed }">
{{ modeLabel }}
</span>
<a v-if="demoUrl" class="demo-link" :href="demoUrl">
{{ messages.appHeaderDemoInstance }}
</a>
<AuthStatus :auth="auth" @logout="$emit('logout')" />
<button class="button" type="button" data-dev-panel-opener @click="openDevPanel">
<span class="full-label">
@ -141,6 +145,18 @@ const modeLabel = computed(() => {
color: var(--c-error);
}
.demo-link {
color: var(--c-text-chip);
text-transform: uppercase;
text-decoration: none;
border-bottom: 1px solid var(--c-line-strong);
}
.demo-link:hover {
color: var(--c-text);
border-color: var(--c-accent);
}
.button {
min-height: 31px;
padding: 6px 11px;

View file

@ -12,7 +12,7 @@ import StreamError from './StreamError.vue'
import StreamWarning from './StreamWarning.vue'
import ThinkingIndicator from './ThinkingIndicator.vue'
const props = defineProps<{ turn: AssistantTurn; canSave: boolean }>()
const props = defineProps<{ turn: AssistantTurn; canSave: boolean; demoUrl: string | null }>()
defineEmits<{ save: []; retry: [query: string] }>()
const thinkingLabel = computed(() => {
@ -54,12 +54,14 @@ const isEmptyResult = computed(
v-if="turn.error"
:code="turn.error.code"
:message="turn.error.message"
:demo-url="demoUrl"
@retry="$emit('retry', turn.query)"
/>
<StreamError
v-else-if="turn.transportFailure"
:code="turn.transportFailure.kind"
:message="turn.transportFailure.message"
:demo-url="demoUrl"
@retry="$emit('retry', turn.query)"
/>

View file

@ -111,6 +111,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
:auth="auth"
:mode="mode"
:health-failed="health.status === 'failed'"
:demo-url="health.demoUrl"
@logout="logout"
@reset="reset"
@toggle-dev="openDevPanel"
@ -122,6 +123,7 @@ onUnmounted(() => window.removeEventListener('keydown', onShortcut))
:turns="turns"
:suggestions="DISCOVERY_SUGGESTIONS"
:can-save="canSave"
:demo-url="health.demoUrl"
@pick="pickSuggestion"
@save="savePlaylist"
@retry="retryQuery"

View file

@ -11,6 +11,7 @@ const props = defineProps<{
turns: ChatTurn[]
suggestions: readonly string[]
canSave: boolean
demoUrl: string | null
}>()
defineEmits<{
pick: [suggestion: string]
@ -107,6 +108,7 @@ watch(streaming, (value) => {
v-else
:turn="turn"
:can-save="canSave"
:demo-url="demoUrl"
@save="$emit('save', turn.id)"
@retry="$emit('retry', $event)"
/>

View file

@ -1,7 +1,7 @@
<script setup lang="ts">
import { messages } from '../lib/messages'
defineProps<{ code: string; message: string }>()
defineProps<{ code: string; message: string; demoUrl?: string | null }>()
defineEmits<{ retry: [] }>()
</script>
@ -9,6 +9,9 @@ defineEmits<{ retry: [] }>()
<div class="error" role="alert" :data-error-code="code">
<p>{{ message }}</p>
<button type="button" @click="$emit('retry')">{{ messages.streamErrorRetry }}</button>
<a v-if="demoUrl" class="demo-link" :href="demoUrl">
{{ messages.streamErrorDemoInstance }}
</a>
</div>
</template>
@ -36,4 +39,16 @@ defineEmits<{ retry: [] }>()
border-radius: var(--r-md);
cursor: pointer;
}
.demo-link {
display: inline-block;
margin-top: var(--s-3);
margin-left: var(--s-3);
padding: 8px var(--s-4);
color: var(--c-text);
font-family: var(--f-mono);
font-size: var(--t-micro);
border: 1px solid var(--c-line-strong);
border-radius: var(--r-md);
}
</style>

View file

@ -35,7 +35,12 @@ function loginFailed(): boolean {
/** Manage authentication, service mode, and playlist API operations. */
export function useApi() {
const auth = ref<AuthState>({ status: 'checking', user: null, message: null })
const health = ref<HealthState>({ status: 'checking', mode: null, message: null })
const health = ref<HealthState>({
status: 'checking',
mode: null,
message: null,
demoUrl: null,
})
async function loadAuth(hadLoginError: boolean): Promise<void> {
try {
@ -81,12 +86,17 @@ export function useApi() {
) {
throw new Error(messages.useApiInvalidHealth)
}
health.value = { status: 'ready', mode: body.mode, message: null }
const demoUrl =
typeof body.demo_url === 'string' && body.demo_url.startsWith('https://')
? body.demo_url
: null
health.value = { status: 'ready', mode: body.mode, message: null, demoUrl }
} catch {
health.value = {
status: 'failed',
mode: null,
message: messages.useApiModeUnavailable,
demoUrl: null,
}
}
}

View file

@ -3,6 +3,7 @@
export const messages = {
appHeaderBrandPrefix: 'discovery',
appHeaderBrandResetLabel: 'discovery-by-llm: start a new chat',
appHeaderDemoInstance: 'demo',
appHeaderBrandAccent: '-by-',
appHeaderBrandSuffix: 'llm',
appHeaderKicker: 'proof of concept',
@ -94,6 +95,7 @@ export const messages = {
resultSetRecommendedTrackLabel: '{count} recommended track',
resultSetRecommendedTracksLabel: '{count} recommended tracks',
streamErrorDemoInstance: 'Open the demo instance',
streamErrorRetry: 'Edit and retry',
suggestionChipsRegionLabel: 'Listening suggestions',

View file

@ -49,9 +49,9 @@ export type AuthState =
| { status: 'failed'; user: null; message: string }
export type HealthState =
| { status: 'checking'; mode: null; message: null }
| { status: 'ready'; mode: AppMode; message: null }
| { status: 'failed'; mode: null; message: string }
| { status: 'checking'; mode: null; message: null; demoUrl: null }
| { status: 'ready'; mode: AppMode; message: null; demoUrl: string | null }
| { status: 'failed'; mode: null; message: string; demoUrl: null }
export interface EventLogEntry {
timestamp: string