feat: banner if API is not reachable

This commit is contained in:
Abhishek Kumar 2026-05-31 13:05:22 +05:30
parent ba342b66a7
commit 78ba62e185
15 changed files with 181 additions and 65 deletions

View file

@ -4,7 +4,7 @@ Welcome to Dograh AI! ❤️ Thank you for your interest in contributing to the
Dograh AI is a comprehensive voice agent platform that helps developers build, test, and deploy conversational AI systems with minimal setup. This guide will help you understand the project structure, set up your development environment, and start contributing effectively.
👉 Join our community → [Dograh Community Slack](https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ)
👉 Join our community → [Dograh Community Slack](https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g)
## 🏗️ Project Overview
@ -40,7 +40,7 @@ Please refer to our [Development Setup documentation](https://docs.dograh.com/co
**Before You Start**
- Check existing [GitHub Issues](../../issues) for similar work
- Join our [Slack community](https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ) to discuss your plans
- Join our [Slack community](https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g) to discuss your plans
- Look for issues tagged `good first issue` for beginner-friendly tasks
**During Development**
@ -58,6 +58,6 @@ Our Slack community is the heart of Dograh AI development:
- **Connect**: Meet other contributors and maintainers
- **Stay Updated**: Learn about contribution opportunities and releases
👉 **Join us**: [Dograh Community Slack](https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ)
👉 **Join us**: [Dograh Community Slack](https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g)
Thank you for helping us keep voice AI open and accessible! 🎉

View file

@ -15,7 +15,7 @@
<img src="https://img.shields.io/badge/⚡_60_秒自托管-一行命令-111827?style=for-the-badge" alt="60 秒自托管">
</a>
&nbsp;
<a href="https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ">
<a href="https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g">
<img src="https://img.shields.io/badge/💬_加入_Slack-社区-4A154B?style=for-the-badge&logo=slack" alt="加入 Slack">
</a>
</p>
@ -144,7 +144,7 @@ curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/m
- **GitHub Discussions** —— 分享使用场景、提问、交流工作流配方。
- **GitHub Issues** —— 报告 bug 或提交功能请求。
👉 加入我们 → [Dograh 社区 Slack](https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ)
👉 加入我们 → [Dograh 社区 Slack](https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g)
## 🙌 参与贡献
@ -178,5 +178,5 @@ Dograh AI 基于 [BSD 2-Clause 协议](LICENSE)开源 —— 与构建 Dograh AI
<p align="center">
<a href="https://github.com/dograh-hq/dograh/stargazers">⭐ 给我们一个 Star</a> |
<a href="https://app.dograh.com">☁️ 试用云端版本</a> |
<a href="https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ">💬 加入 Slack</a>
<a href="https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g">💬 加入 Slack</a>
</p>

View file

@ -244,7 +244,8 @@ class _ToolDocumentRefsMixin(BaseModel):
"display_name": "Greeting Text",
"description": (
"Text spoken via TTS at the start of the call. Supports "
"{{template_variables}}. Leave empty to skip the greeting."
"{{template_variables}}. Leave empty to skip the greeting. "
"Not supported with realtime (speech-to-speech) models."
),
"display_options": DisplayOptions(show={"greeting_type": ["text"]}),
"placeholder": "Hi {{first_name}}, this is Sarah from Acme.",

View file

@ -41,4 +41,4 @@ One-click Heroku deployment is in development. This will include:
- Environment variable configuration guide
- Scaling and monitoring instructions
For updates on Heroku deployment availability, please [join our Slack community](https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ) or watch our GitHub repository for announcements.
For updates on Heroku deployment availability, please [join our Slack community](https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g) or watch our GitHub repository for announcements.

View file

@ -47,4 +47,4 @@ Our integration system follows these core principles:
- Check provider-specific documentation for detailed setup instructions
- Visit our [GitHub Issues](https://github.com/dograh-hq/dograh/issues) for community support
- Join our [Slack community](https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ) for assistance
- Join our [Slack community](https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g) for assistance

@ -1 +1 @@
Subproject commit b0ac013a08cf74131a93afc5213af6b4802e5871
Subproject commit 6e410e06cc9f71fbadfb3850f87848d2288d6651

View file

@ -1,32 +1,64 @@
import { NextResponse } from "next/server";
import { healthApiV1HealthGet } from "@/client/sdk.gen";
import type { HealthResponse } from "@/client/types.gen";
import { getServerBackendUrl } from "@/lib/apiClient";
// Import version from package.json at build time
import packageJson from "../../../../../package.json";
const HEALTHCHECK_TIMEOUT_MS = 3000;
function trimTrailingSlash(url: string) {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
function getHealthcheckFailureMessage(error: unknown, backendUrl: string) {
const errorName =
error && typeof error === "object" && "name" in error
? String((error as { name?: unknown }).name)
: "";
if (errorName === "AbortError" || errorName === "TimeoutError") {
return `Backend health check timed out after ${HEALTHCHECK_TIMEOUT_MS}ms while trying to reach ${backendUrl}.`;
}
return `Backend is not reachable at ${backendUrl}.`;
}
export async function GET() {
const uiVersion = packageJson.version || "dev";
const backendUrl = trimTrailingSlash(getServerBackendUrl());
const healthcheckUrl = `${backendUrl}/api/v1/health`;
let apiVersion = "unknown";
let deploymentMode = "oss";
let authProvider = "local";
let turnEnabled = false;
let forceTurnRelay = false;
let backendStatus: "reachable" | "unreachable" = "unreachable";
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
try {
const response = await healthApiV1HealthGet();
if (response.data) {
const data = response.data as HealthResponse;
const response = await fetch(healthcheckUrl, {
cache: "no-store",
signal: AbortSignal.timeout(HEALTHCHECK_TIMEOUT_MS),
});
if (!response.ok) {
backendMessage = `Backend health check at ${healthcheckUrl} returned HTTP ${response.status}.`;
} else {
const data = (await response.json()) as HealthResponse;
apiVersion = data.version;
deploymentMode = data.deployment_mode;
authProvider = data.auth_provider;
turnEnabled = Boolean(data.turn_enabled);
forceTurnRelay = Boolean(data.force_turn_relay);
backendStatus = "reachable";
backendMessage = null;
}
} catch {
} catch (error) {
apiVersion = "unavailable";
backendMessage = getHealthcheckFailureMessage(error, backendUrl);
}
return NextResponse.json({
@ -36,5 +68,11 @@ export async function GET() {
authProvider,
turnEnabled,
forceTurnRelay,
backend: {
status: backendStatus,
url: backendUrl,
healthcheckUrl,
message: backendMessage,
},
});
}

View file

@ -1,9 +1,7 @@
"use client";
import { AlertCircle } from "lucide-react";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { RecordingSelect } from "@/components/flow/TextOrAudioInput";
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -144,10 +142,7 @@ export function EndCallToolConfig({
</div>
{messageType === "custom" && (
<div className="pl-8 space-y-2">
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
</div>
<StaticTextWarning />
<Textarea
value={customMessage}
onChange={(e) => onCustomMessageChange(e.target.value)}

View file

@ -1,9 +1,7 @@
"use client";
import { AlertCircle } from "lucide-react";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
import { StaticTextWarning, TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
import {
CredentialSelector,
type HttpMethod,
@ -164,10 +162,7 @@ export function HttpApiToolConfig({
recordings={recordings}
>
<>
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
</div>
<StaticTextWarning />
<Textarea
value={customMessage}
onChange={(e) => onCustomMessageChange(e.target.value)}

View file

@ -1,10 +1,9 @@
"use client";
import { AlertCircle } from "lucide-react";
import {useState } from "react";
import type { RecordingResponseSchema } from "@/client/types.gen";
import { RecordingSelect } from "@/components/flow/TextOrAudioInput";
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -177,10 +176,7 @@ export function TransferCallToolConfig({
</div>
{messageType === "custom" && (
<div className="pl-8 space-y-2">
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
</div>
<StaticTextWarning />
<Textarea
value={customMessage}
onChange={(e) => onCustomMessageChange(e.target.value)}

View file

@ -1,4 +1,4 @@
import { Check, ChevronDown, Pause, Play, Search } from "lucide-react";
import { AlertCircle, Check, ChevronDown, Pause, Play, Search } from "lucide-react";
import { useMemo, useState } from "react";
import type { RecordingResponseSchema } from "@/client/types.gen";
@ -10,6 +10,25 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
import { cn } from "@/lib/utils";
/**
* Amber caveat shown next to free-text fields that are spoken aloud via TTS
* (greetings, transition speech, custom tool messages). Two warnings: the text
* is voiced verbatim (matters for multilingual flows), and realtime
* (speech-to-speech) models have no TTS stage, so static text is never spoken
* a pre-recorded audio file should be used instead.
*/
export function StaticTextWarning() {
return (
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.
Realtime (speech-to-speech) models can&apos;t play static text.
</span>
</div>
);
}
interface TextOrAudioInputProps {
type: 'text' | 'audio';
onTypeChange: (type: 'text' | 'audio') => void;

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
import { useWorkflow, useWorkflowOptional } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
import { TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
import { StaticTextWarning, TextOrAudioInput } from "@/components/flow/TextOrAudioInput";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@ -122,10 +122,7 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
recordings={recordings ?? []}
>
<>
<div className="flex items-start gap-2 rounded-md bg-amber-50 p-2 text-xs text-amber-700 border border-amber-200">
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>This text is spoken as-is. For multilingual workflows, choose your phrasing carefully.</span>
</div>
<StaticTextWarning />
<Textarea
value={transitionSpeech}
placeholder="e.g. Let me transfer you to our billing department..."

View file

@ -1,6 +1,6 @@
"use client";
import { Menu } from "lucide-react";
import { AlertTriangle, Menu, RefreshCw } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import posthog from "posthog-js";
@ -9,6 +9,7 @@ import React, { ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { SidebarInset, SidebarProvider, useSidebar } from "@/components/ui/sidebar";
import { PostHogEvent } from "@/constants/posthog-events";
import { useAppConfig } from "@/context/AppConfigContext";
import { AppSidebar } from "./AppSidebar";
import { GitHubStarBadge } from "./GitHubStarBadge";
@ -27,7 +28,7 @@ function AppHeader() {
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<a
href="https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ"
href="https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g"
target="_blank"
rel="noopener noreferrer"
onClick={() => posthog.capture(PostHogEvent.SLACK_COMMUNITY_CLICKED, { source: "app_header" })}
@ -45,6 +46,46 @@ function AppHeader() {
);
}
function BackendStatusBanner() {
const { config, loading, refresh } = useAppConfig();
if (!config || config.backendStatus === "reachable") {
return null;
}
const backendUrl = config.backendUrl && config.backendUrl !== "unknown"
? config.backendUrl
: "the configured backend";
const message = config.backendMessage || `Backend is not reachable at ${backendUrl}.`;
return (
<div
role="alert"
className="border-b border-amber-300 bg-amber-50 px-4 py-3 text-amber-950 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0" />
<div className="min-w-0">
<p className="text-sm font-semibold">Backend connection failed</p>
<p className="break-words text-sm">{message}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void refresh()}
disabled={loading}
className="h-8 shrink-0 border-amber-400 bg-transparent text-amber-950 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/40"
>
<RefreshCw className="h-4 w-4" />
Retry
</Button>
</div>
</div>
);
}
interface AppLayoutProps {
children: ReactNode;
headerActions?: ReactNode;
@ -73,6 +114,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
<div className="flex min-h-screen w-full">
<AppSidebar />
<SidebarInset className="flex-1">
<BackendStatusBanner />
{!isWorkflowEditor && <AppHeader />}
{/* Optional header area for specific pages */}
{headerActions && (
@ -104,6 +146,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({
</div>
) : (
<div className="flex-1 w-full">
<BackendStatusBanner />
{children}
</div>
)}

View file

@ -1,6 +1,8 @@
'use client';
import { createContext, ReactNode, useContext, useEffect, useState } from 'react';
import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
type BackendStatus = 'reachable' | 'unreachable';
interface AppConfig {
uiVersion: string;
@ -9,54 +11,80 @@ interface AppConfig {
authProvider: string;
turnEnabled: boolean;
forceTurnRelay: boolean;
backendStatus: BackendStatus;
backendUrl: string;
backendMessage: string | null;
}
interface AppConfigContextType {
config: AppConfig | null;
loading: boolean;
refresh: () => Promise<void>;
}
const defaultConfig: AppConfig = {
uiVersion: 'dev',
apiVersion: 'unknown',
apiVersion: 'unavailable',
deploymentMode: 'oss',
authProvider: 'local',
turnEnabled: false,
forceTurnRelay: false,
backendStatus: 'unreachable',
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown',
backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL
? `Unable to verify backend health at ${process.env.NEXT_PUBLIC_BACKEND_URL}.`
: 'Unable to verify backend health.',
};
const AppConfigContext = createContext<AppConfigContextType>({
config: null,
loading: true,
refresh: async () => { },
});
export function AppConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<AppConfig | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/config/version')
.then((res) => res.json())
.then((data) => {
setConfig({
uiVersion: data.ui || 'dev',
apiVersion: data.api || 'unknown',
deploymentMode: data.deploymentMode || 'oss',
authProvider: data.authProvider || 'local',
turnEnabled: Boolean(data.turnEnabled),
forceTurnRelay: Boolean(data.forceTurnRelay),
});
})
.catch(() => {
setConfig(defaultConfig);
})
.finally(() => {
setLoading(false);
const loadConfig = useCallback(async () => {
setLoading(true);
try {
const response = await fetch('/api/config/version', { cache: 'no-store' });
const data = await response.json();
const backend = data.backend && typeof data.backend === 'object' ? data.backend : {};
const backendStatus: BackendStatus = backend.status === 'reachable' ? 'reachable' : 'unreachable';
const backendUrl = typeof backend.url === 'string' && backend.url.length > 0
? backend.url
: defaultConfig.backendUrl;
setConfig({
uiVersion: data.ui || 'dev',
apiVersion: data.api || 'unknown',
deploymentMode: data.deploymentMode || 'oss',
authProvider: data.authProvider || 'local',
turnEnabled: Boolean(data.turnEnabled),
forceTurnRelay: Boolean(data.forceTurnRelay),
backendStatus,
backendUrl,
backendMessage: typeof backend.message === 'string' && backend.message.length > 0
? backend.message
: backendStatus === 'reachable'
? null
: `Backend is not reachable at ${backendUrl}.`,
});
} catch {
setConfig(defaultConfig);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadConfig();
}, [loadConfig]);
return (
<AppConfigContext.Provider value={{ config, loading }}>
<AppConfigContext.Provider value={{ config, loading, refresh: loadConfig }}>
{children}
</AppConfigContext.Provider>
);

View file

@ -1,13 +1,17 @@
import type { Client } from '@/client/client';
import type { CreateClientConfig } from '@/client/client.gen';
export function getServerBackendUrl() {
return process.env.BACKEND_URL || 'http://api:8000';
}
export const createClientConfig: CreateClientConfig = (config) => {
// Use different URLs for server-side vs client-side
const isServer = typeof window === 'undefined';
let baseUrl: string;
if (isServer) {
baseUrl = process.env.BACKEND_URL || 'http://api:8000';
baseUrl = getServerBackendUrl();
} else {
baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin;
}