feat(developer): add hybrid iframe integration for developer tools
Replace placeholder pages with ToolEmbed component that embeds LangGraph, LangFlow, and Langfuse in iframes when configured, or shows "Not Configured" placeholders when URLs are empty. Add ServiceUrlsContext for passing service URLs through Dioxus context. Add docker-compose services for local development: LangFlow, LangGraph (trial), Langfuse with full dependency stack (Postgres, ClickHouse, Redis, MinIO). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
81
src/components/tool_embed.rs
Normal file
81
src/components/tool_embed.rs
Normal 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}",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
43
src/models/services.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user