feat: added langflow, langfuse and langgraph integrations #17

Merged
sharang merged 8 commits from test/add-unit-test-suite into main 2026-02-25 20:08:49 +00:00
21 changed files with 467 additions and 52 deletions
Showing only changes of commit a24ea984b1 - Show all commits

View File

@@ -66,10 +66,11 @@ STRIPE_WEBHOOK_SECRET=
STRIPE_PUBLISHABLE_KEY=
# ---------------------------------------------------------------------------
# LangChain / LangGraph / Langfuse [OPTIONAL]
# LangChain / LangGraph / LangFlow / Langfuse [OPTIONAL]
# ---------------------------------------------------------------------------
LANGCHAIN_URL=
LANGGRAPH_URL=
LANGFLOW_URL=
LANGFUSE_URL=
# ---------------------------------------------------------------------------

View File

@@ -154,6 +154,9 @@ jobs:
MONGODB_URI: mongodb://root:example@mongo:27017
MONGODB_DATABASE: certifai
SEARXNG_URL: http://searxng:8080
LANGGRAPH_URL: ""
LANGFLOW_URL: ""
LANGFUSE_URL: ""
steps:
- name: Checkout
run: |

View File

@@ -96,7 +96,9 @@
"total_requests": "Anfragen gesamt",
"avg_latency": "Durchschn. Latenz",
"tokens_used": "Verbrauchte Token",
"error_rate": "Fehlerrate"
"error_rate": "Fehlerrate",
"not_configured": "Nicht konfiguriert",
"open_new_tab": "In neuem Tab oeffnen"
},
"org": {
"title": "Organisation",

View File

@@ -96,7 +96,9 @@
"total_requests": "Total Requests",
"avg_latency": "Avg Latency",
"tokens_used": "Tokens Used",
"error_rate": "Error Rate"
"error_rate": "Error Rate",
"not_configured": "Not Configured",
"open_new_tab": "Open in New Tab"
},
"org": {
"title": "Organization",

View File

@@ -96,7 +96,9 @@
"total_requests": "Total de solicitudes",
"avg_latency": "Latencia promedio",
"tokens_used": "Tokens utilizados",
"error_rate": "Tasa de errores"
"error_rate": "Tasa de errores",
"not_configured": "No configurado",
"open_new_tab": "Abrir en nueva pestana"
},
"org": {
"title": "Organizacion",

View File

@@ -96,7 +96,9 @@
"total_requests": "Requetes totales",
"avg_latency": "Latence moyenne",
"tokens_used": "Tokens utilises",
"error_rate": "Taux d'erreur"
"error_rate": "Taux d'erreur",
"not_configured": "Non configure",
"open_new_tab": "Ouvrir dans un nouvel onglet"
},
"org": {
"title": "Organisation",

View File

@@ -96,7 +96,9 @@
"total_requests": "Total de Pedidos",
"avg_latency": "Latencia Media",
"tokens_used": "Tokens Utilizados",
"error_rate": "Taxa de Erros"
"error_rate": "Taxa de Erros",
"not_configured": "Nao configurado",
"open_new_tab": "Abrir em novo separador"
},
"org": {
"title": "Organizacao",

View File

@@ -2591,6 +2591,58 @@ h6 {
border-radius: 20px;
}
/* ===== Tool Embed (iframe integration) ===== */
.tool-embed {
display: flex;
flex-direction: column;
flex: 1;
height: calc(100vh - 60px);
min-height: 400px;
}
.tool-embed-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background-color: var(--bg-card);
border-bottom: 1px solid var(--border-primary);
}
.tool-embed-title {
font-family: 'Space Grotesk', sans-serif;
font-size: 16px;
font-weight: 600;
color: var(--text-heading);
}
.tool-embed-popout-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 13px;
font-weight: 500;
color: var(--accent);
background-color: transparent;
border: 1px solid var(--accent);
border-radius: 6px;
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.tool-embed-popout-btn:hover {
background-color: var(--accent);
color: var(--bg-body);
}
.tool-embed-iframe {
flex: 1;
width: 100%;
border: none;
}
/* ===== Analytics Stats Bar ===== */
.analytics-stats-bar {
display: flex;

View File

@@ -94,5 +94,155 @@ services:
- ./librechat/openidStrategy.js:/app/api/strategies/openidStrategy.js:ro
- librechat-data:/app/data
langflow:
image: langflowai/langflow:latest
container_name: certifai-langflow
restart: unless-stopped
ports:
- "7860:7860"
environment:
LANGFLOW_AUTO_LOGIN: "true"
langgraph:
image: langchain/langgraph-trial:3.12
container_name: certifai-langgraph
restart: unless-stopped
depends_on:
langgraph-db:
condition: service_started
langgraph-redis:
condition: service_started
ports:
- "8123:8000"
environment:
DATABASE_URI: postgresql://langgraph:langgraph@langgraph-db:5432/langgraph
REDIS_URI: redis://langgraph-redis:6379
langgraph-db:
image: postgres:16
container_name: certifai-langgraph-db
restart: unless-stopped
environment:
POSTGRES_USER: langgraph
POSTGRES_PASSWORD: langgraph
POSTGRES_DB: langgraph
volumes:
- langgraph-db-data:/var/lib/postgresql/data
langgraph-redis:
image: redis:7-alpine
container_name: certifai-langgraph-redis
restart: unless-stopped
langfuse:
image: langfuse/langfuse:3
container_name: certifai-langfuse
restart: unless-stopped
depends_on:
langfuse-db:
condition: service_healthy
langfuse-clickhouse:
condition: service_healthy
langfuse-redis:
condition: service_healthy
langfuse-minio:
condition: service_healthy
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://langfuse:langfuse@langfuse-db:5432/langfuse
NEXTAUTH_URL: http://localhost:3000
NEXTAUTH_SECRET: certifai-langfuse-dev-secret
SALT: certifai-langfuse-dev-salt
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
CLICKHOUSE_URL: http://langfuse-clickhouse:8123
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
CLICKHOUSE_CLUSTER_ENABLED: "false"
REDIS_HOST: langfuse-redis
REDIS_PORT: "6379"
REDIS_AUTH: langfuse-dev-redis
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_EVENT_UPLOAD_REGION: auto
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://langfuse-minio:9000
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://langfuse-minio:9000
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
langfuse-db:
image: postgres:16
container_name: certifai-langfuse-db
restart: unless-stopped
environment:
POSTGRES_USER: langfuse
POSTGRES_PASSWORD: langfuse
POSTGRES_DB: langfuse
volumes:
- langfuse-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U langfuse"]
interval: 5s
timeout: 5s
retries: 10
langfuse-clickhouse:
image: clickhouse/clickhouse-server:latest
container_name: certifai-langfuse-clickhouse
restart: unless-stopped
user: "101:101"
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
ulimits:
nofile:
soft: 262144
hard: 262144
volumes:
- langfuse-clickhouse-data:/var/lib/clickhouse
- langfuse-clickhouse-logs:/var/log/clickhouse-server
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"]
interval: 5s
timeout: 5s
retries: 10
langfuse-redis:
image: redis:7-alpine
container_name: certifai-langfuse-redis
restart: unless-stopped
command: redis-server --requirepass langfuse-dev-redis
healthcheck:
test: ["CMD", "redis-cli", "-a", "langfuse-dev-redis", "ping"]
interval: 5s
timeout: 5s
retries: 10
langfuse-minio:
image: cgr.dev/chainguard/minio
container_name: certifai-langfuse-minio
restart: unless-stopped
entrypoint: sh
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: miniosecret
healthcheck:
test: ["CMD-SHELL", "mc ready local || exit 1"]
interval: 5s
timeout: 5s
retries: 10
volumes:
librechat-data:
langgraph-db-data:
langfuse-db-data:
langfuse-clickhouse-data:
langfuse-clickhouse-logs:

View File

@@ -11,23 +11,50 @@ test.describe("Developer section", () => {
await expect(nav.locator("a", { hasText: "Analytics" })).toBeVisible();
});
test("agents page shows Coming Soon badge", async ({ page }) => {
test("agents page shows Not Configured when URL is empty", async ({
page,
}) => {
await page.goto("/developer/agents");
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
await expect(page.locator(".placeholder-badge")).toContainText(
"Coming Soon"
"Not Configured"
);
await expect(page.locator("h2")).toContainText("Agent Builder");
});
test("analytics page loads via sub-nav", async ({ page }) => {
test("analytics page shows Not Configured when URL is empty", async ({
page,
}) => {
await page.goto("/developer/analytics");
await page.waitForSelector(".placeholder-page", { timeout: 15_000 });
await expect(page.locator("h2")).toContainText("Analytics");
await expect(
page.locator("h2", { hasText: "Analytics" })
).toBeVisible();
await expect(page.locator(".placeholder-badge")).toContainText(
"Coming Soon"
"Not Configured"
);
});
test("agents page shows iframe when URL is configured", async ({
page,
}) => {
// This test only runs meaningfully when LANGGRAPH_URL is set in the
// environment. When empty, the placeholder is shown instead.
await page.goto("/developer/agents");
await page.waitForTimeout(2000);
const iframe = page.locator(".tool-embed-iframe");
const placeholder = page.locator(".placeholder-badge");
if (await placeholder.isVisible()) {
await expect(placeholder).toContainText("Not Configured");
} else {
await expect(iframe).toBeVisible();
await expect(
page.locator(".tool-embed-popout-btn")
).toBeVisible();
}
});
});

View File

@@ -5,7 +5,7 @@ use dioxus_free_icons::Icon;
use crate::components::sidebar::Sidebar;
use crate::i18n::{t, tw, Locale};
use crate::infrastructure::auth_check::check_auth;
use crate::models::AuthInfo;
use crate::models::{AuthInfo, ServiceUrlsContext};
use crate::Route;
/// Application shell layout that wraps all authenticated pages.
@@ -29,6 +29,16 @@ pub fn AppShell() -> Element {
match auth_snapshot {
Some(Ok(info)) if info.authenticated => {
// Provide developer tool URLs as context so child pages
// can read them without prop-drilling through layouts.
use_context_provider(|| {
Signal::new(ServiceUrlsContext {
langgraph_url: info.langgraph_url.clone(),
langflow_url: info.langflow_url.clone(),
langfuse_url: info.langfuse_url.clone(),
})
});
let menu_open = *mobile_menu_open.read();
let sidebar_cls = if menu_open {
"sidebar sidebar--open"

View File

@@ -9,6 +9,7 @@ mod page_header;
mod pricing_card;
pub mod sidebar;
pub mod sub_nav;
mod tool_embed;
pub use app_shell::*;
pub use article_detail::*;
@@ -20,3 +21,4 @@ pub use news_card::*;
pub use page_header::*;
pub use pricing_card::*;
pub use sub_nav::*;
pub use tool_embed::*;

View File

@@ -0,0 +1,81 @@
use dioxus::prelude::*;
use crate::i18n::{t, Locale};
/// Properties for the [`ToolEmbed`] component.
///
/// # Fields
///
/// * `url` - Service URL; when empty, a "Not Configured" placeholder is shown
/// * `title` - Display title for the tool (e.g. "Agent Builder")
/// * `description` - Description text shown in the placeholder card
/// * `icon` - Single-character icon for the placeholder card
/// * `launch_label` - Label for the disabled button in the placeholder
#[derive(Props, Clone, PartialEq)]
pub struct ToolEmbedProps {
/// Service URL. Empty string means "not configured".
pub url: String,
/// Display title shown in the toolbar / placeholder heading.
pub title: String,
/// Description shown in the "not configured" placeholder.
pub description: String,
/// Single-character icon for the placeholder card.
pub icon: &'static str,
/// Label for the disabled launch button in placeholder mode.
pub launch_label: String,
}
/// Hybrid iframe / placeholder component for developer tool pages.
///
/// When `url` is non-empty, renders a toolbar (title + pop-out button)
/// above a full-height iframe embedding the service. When `url` is
/// empty, renders the existing placeholder card with a "Not Configured"
/// badge instead of "Coming Soon".
#[component]
pub fn ToolEmbed(props: ToolEmbedProps) -> Element {
let locale = use_context::<Signal<Locale>>();
let l = *locale.read();
if props.url.is_empty() {
// Not configured -- show placeholder card
rsx! {
section { class: "placeholder-page",
div { class: "placeholder-card",
div { class: "placeholder-icon", "{props.icon}" }
h2 { "{props.title}" }
p { class: "placeholder-desc", "{props.description}" }
button {
class: "btn-primary",
disabled: true,
"{props.launch_label}"
}
span { class: "placeholder-badge",
"{t(l, \"developer.not_configured\")}"
}
}
}
}
} else {
// URL is set -- render toolbar + iframe
let pop_out_url = props.url.clone();
rsx! {
div { class: "tool-embed",
div { class: "tool-embed-toolbar",
span { class: "tool-embed-title", "{props.title}" }
a {
class: "tool-embed-popout-btn",
href: "{pop_out_url}",
target: "_blank",
rel: "noopener noreferrer",
"{t(l, \"developer.open_new_tab\")}"
}
}
iframe {
class: "tool-embed-iframe",
src: "{props.url}",
title: "{props.title}",
}
}
}
}
}

View File

@@ -27,6 +27,15 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
Some(u) => {
let librechat_url =
std::env::var("LIBRECHAT_URL").unwrap_or_else(|_| "http://localhost:3080".into());
// Extract service URLs from server state so the frontend can
// embed developer tools (LangGraph, LangFlow, Langfuse).
let state: crate::infrastructure::server_state::ServerState =
FullstackContext::extract().await?;
let langgraph_url = state.services.langgraph_url.clone();
let langflow_url = state.services.langflow_url.clone();
let langfuse_url = state.services.langfuse_url.clone();
Ok(AuthInfo {
authenticated: true,
sub: u.sub,
@@ -34,6 +43,9 @@ pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
name: u.user.name,
avatar_url: u.user.avatar_url,
librechat_url,
langgraph_url,
langflow_url,
langfuse_url,
})
}
None => Ok(AuthInfo::default()),

View File

@@ -154,6 +154,8 @@ pub struct ServiceUrls {
pub langchain_url: String,
/// LangGraph service URL.
pub langgraph_url: String,
/// LangFlow visual workflow builder URL.
pub langflow_url: String,
/// Langfuse observability URL.
pub langfuse_url: String,
/// Vector database URL.
@@ -183,6 +185,7 @@ impl ServiceUrls {
.unwrap_or_else(|_| "http://localhost:8888".into()),
langchain_url: optional_env("LANGCHAIN_URL"),
langgraph_url: optional_env("LANGGRAPH_URL"),
langflow_url: optional_env("LANGFLOW_URL"),
langfuse_url: optional_env("LANGFUSE_URL"),
vectordb_url: optional_env("VECTORDB_URL"),
s3_url: optional_env("S3_URL"),

View File

@@ -3,6 +3,7 @@ mod developer;
mod news;
mod organization;
mod provider;
mod services;
mod user;
pub use chat::*;
@@ -10,4 +11,5 @@ pub use developer::*;
pub use news::*;
pub use organization::*;
pub use provider::*;
pub use services::*;
pub use user::*;

43
src/models/services.rs Normal file
View File

@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
/// Frontend-facing URLs for developer tool services.
///
/// Provided as a context signal in `AppShell` so that developer pages
/// can read the configured URLs without threading props through layouts.
/// An empty string indicates the service is not configured.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ServiceUrlsContext {
/// LangGraph agent builder URL (empty if not configured)
pub langgraph_url: String,
/// LangFlow visual workflow builder URL (empty if not configured)
pub langflow_url: String,
/// Langfuse observability URL (empty if not configured)
pub langfuse_url: String,
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn default_urls_are_empty() {
let ctx = ServiceUrlsContext::default();
assert_eq!(ctx.langgraph_url, "");
assert_eq!(ctx.langflow_url, "");
assert_eq!(ctx.langfuse_url, "");
}
#[test]
fn serde_round_trip() {
let ctx = ServiceUrlsContext {
langgraph_url: "http://localhost:8123".into(),
langflow_url: "http://localhost:7860".into(),
langfuse_url: "http://localhost:3000".into(),
};
let json = serde_json::to_string(&ctx).expect("serialize ServiceUrlsContext");
let back: ServiceUrlsContext =
serde_json::from_str(&json).expect("deserialize ServiceUrlsContext");
assert_eq!(ctx, back);
}
}

View File

@@ -24,6 +24,12 @@ pub struct AuthInfo {
pub avatar_url: String,
/// LibreChat instance URL for the sidebar chat link
pub librechat_url: String,
/// LangGraph agent builder URL (empty if not configured)
pub langgraph_url: String,
/// LangFlow visual workflow builder URL (empty if not configured)
pub langflow_url: String,
/// Langfuse observability URL (empty if not configured)
pub langfuse_url: String,
}
/// Per-user LLM provider configuration stored in MongoDB.
@@ -91,6 +97,9 @@ mod tests {
assert_eq!(info.name, "");
assert_eq!(info.avatar_url, "");
assert_eq!(info.librechat_url, "");
assert_eq!(info.langgraph_url, "");
assert_eq!(info.langflow_url, "");
assert_eq!(info.langfuse_url, "");
}
#[test]
@@ -102,6 +111,9 @@ mod tests {
name: "Test User".into(),
avatar_url: "https://example.com/avatar.png".into(),
librechat_url: "https://chat.example.com".into(),
langgraph_url: "http://localhost:8123".into(),
langflow_url: "http://localhost:7860".into(),
langfuse_url: "http://localhost:3000".into(),
};
let json = serde_json::to_string(&info).expect("serialize AuthInfo");
let back: AuthInfo = serde_json::from_str(&json).expect("deserialize AuthInfo");

View File

@@ -1,27 +1,27 @@
use dioxus::prelude::*;
use crate::components::ToolEmbed;
use crate::i18n::{t, Locale};
use crate::models::ServiceUrlsContext;
/// Agents page placeholder for the LangGraph agent builder.
/// Agents page embedding the LangGraph agent builder.
///
/// Shows a "Coming Soon" card with a disabled launch button.
/// Will eventually integrate with the LangGraph framework.
/// When `langgraph_url` is configured, embeds the service in an iframe
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
#[component]
pub fn AgentsPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
let l = *locale.read();
let url = svc.read().langgraph_url.clone();
rsx! {
section { class: "placeholder-page",
div { class: "placeholder-card",
div { class: "placeholder-icon", "A" }
h2 { "{t(l, \"developer.agents_title\")}" }
p { class: "placeholder-desc",
"{t(l, \"developer.agents_desc\")}"
}
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" }
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
}
ToolEmbed {
url,
title: t(l, "developer.agents_title"),
description: t(l, "developer.agents_desc"),
icon: "A",
launch_label: t(l, "developer.launch_agents"),
}
}
}

View File

@@ -1,16 +1,20 @@
use dioxus::prelude::*;
use crate::components::ToolEmbed;
use crate::i18n::{t, Locale};
use crate::models::AnalyticsMetric;
use crate::models::{AnalyticsMetric, ServiceUrlsContext};
/// Analytics page placeholder for LangFuse integration.
/// Analytics page embedding Langfuse for observability.
///
/// Shows a "Coming Soon" card with a disabled launch button,
/// plus a mock stats bar showing sample metrics.
/// Always shows a stats bar with sample metrics. Below that, when
/// `langfuse_url` is configured, embeds the service in an iframe
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
#[component]
pub fn AnalyticsPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
let l = *locale.read();
let url = svc.read().langfuse_url.clone();
let metrics = mock_metrics(l);
@@ -21,21 +25,24 @@ pub fn AnalyticsPage() -> Element {
div { class: "analytics-stat",
span { class: "analytics-stat-value", "{metric.value}" }
span { class: "analytics-stat-label", "{metric.label}" }
span { class: if metric.change_pct >= 0.0 { "analytics-stat-change analytics-stat-change--up" } else { "analytics-stat-change analytics-stat-change--down" },
span {
class: if metric.change_pct >= 0.0 {
"analytics-stat-change analytics-stat-change--up"
} else {
"analytics-stat-change analytics-stat-change--down"
},
"{metric.change_pct:+.1}%"
}
}
}
}
div { class: "placeholder-card",
div { class: "placeholder-icon", "L" }
h2 { "{t(l, \"developer.analytics_title\")}" }
p { class: "placeholder-desc",
"{t(l, \"developer.analytics_desc\")}"
}
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" }
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
}
}
ToolEmbed {
url,
title: t(l, "developer.analytics_title"),
description: t(l, "developer.analytics_desc"),
icon: "L",
launch_label: t(l, "developer.launch_analytics"),
}
}
}

View File

@@ -1,27 +1,27 @@
use dioxus::prelude::*;
use crate::components::ToolEmbed;
use crate::i18n::{t, Locale};
use crate::models::ServiceUrlsContext;
/// Flow page placeholder for the LangFlow visual workflow builder.
/// Flow page embedding the LangFlow visual workflow builder.
///
/// Shows a "Coming Soon" card with a disabled launch button.
/// Will eventually integrate with LangFlow for visual flow design.
/// When `langflow_url` is configured, embeds the service in an iframe
/// with a pop-out button. Otherwise shows a "Not Configured" placeholder.
#[component]
pub fn FlowPage() -> Element {
let locale = use_context::<Signal<Locale>>();
let svc = use_context::<Signal<ServiceUrlsContext>>();
let l = *locale.read();
let url = svc.read().langflow_url.clone();
rsx! {
section { class: "placeholder-page",
div { class: "placeholder-card",
div { class: "placeholder-icon", "F" }
h2 { "{t(l, \"developer.flow_title\")}" }
p { class: "placeholder-desc",
"{t(l, \"developer.flow_desc\")}"
}
button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_flow\")}" }
span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" }
}
ToolEmbed {
url,
title: t(l, "developer.flow_title"),
description: t(l, "developer.flow_desc"),
icon: "F",
launch_label: t(l, "developer.launch_flow"),
}
}
}