diff --git a/apps/web/src/app/api/ai/agent/chat/route.ts b/apps/web/src/app/api/ai/agent/chat/route.ts new file mode 100644 index 0000000..6b1755e --- /dev/null +++ b/apps/web/src/app/api/ai/agent/chat/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from "next/server"; + +function getUpstreamUrl(baseUrl: string) { + return `${baseUrl.replace(/\/+$/, "")}/chat/completions`; +} + +export async function POST(request: NextRequest) { + const baseUrl = request.nextUrl.searchParams.get("baseUrl"); + + if (!baseUrl) { + return NextResponse.json( + { error: "Missing baseUrl query parameter" }, + { status: 400 }, + ); + } + + try { + const body = await request.text(); + const authorization = request.headers.get("authorization"); + + const upstreamResponse = await fetch(getUpstreamUrl(baseUrl), { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(authorization ? { Authorization: authorization } : {}), + }, + body, + }); + + if (!upstreamResponse.body) { + const errorText = await upstreamResponse.text(); + return new NextResponse(errorText, { + status: upstreamResponse.status, + headers: { + "Content-Type": + upstreamResponse.headers.get("content-type") ?? "text/plain", + }, + }); + } + + return new NextResponse(upstreamResponse.body, { + status: upstreamResponse.status, + headers: { + "Content-Type": + upstreamResponse.headers.get("content-type") ?? + "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); + } catch (error) { + console.error("Agent chat proxy error:", error); + return NextResponse.json( + { error: "Proxy request failed" }, + { status: 502 }, + ); + } +} diff --git a/apps/web/src/components/landing/__tests__/hero-particles.test.ts b/apps/web/src/components/landing/__tests__/hero-particles.test.ts new file mode 100644 index 0000000..ae2fb9d --- /dev/null +++ b/apps/web/src/components/landing/__tests__/hero-particles.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test"; +import { getFloatingParticles } from "../hero-particles"; + +describe("getFloatingParticles", () => { + test("returns stable particle coordinates across calls", () => { + expect(getFloatingParticles()).toEqual(getFloatingParticles()); + expect(getFloatingParticles()).toHaveLength(6); + }); +}); diff --git a/apps/web/src/components/landing/hero-particles.ts b/apps/web/src/components/landing/hero-particles.ts new file mode 100644 index 0000000..b110845 --- /dev/null +++ b/apps/web/src/components/landing/hero-particles.ts @@ -0,0 +1,21 @@ +export type FloatingParticle = { + id: number; + size: number; + x: number; + y: number; + duration: number; + delay: number; +}; + +const FLOATING_PARTICLES: FloatingParticle[] = [ + { id: 0, size: 3.1, x: 14, y: 18, duration: 24, delay: -3 }, + { id: 1, size: 4.2, x: 27, y: 62, duration: 31, delay: -11 }, + { id: 2, size: 2.8, x: 43, y: 26, duration: 20, delay: -7 }, + { id: 3, size: 4.6, x: 61, y: 74, duration: 28, delay: -15 }, + { id: 4, size: 3.4, x: 78, y: 33, duration: 22, delay: -5 }, + { id: 5, size: 2.5, x: 89, y: 57, duration: 26, delay: -18 }, +]; + +export function getFloatingParticles() { + return FLOATING_PARTICLES; +} diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 4d384f1..52ecc1e 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -7,15 +7,9 @@ import { Link } from "@/lib/navigation"; import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; import { motion } from "motion/react"; import { useTranslation } from "@i18next-toolkit/nextjs-approuter"; +import { getFloatingParticles } from "./hero-particles"; -const floatingParticles = Array.from({ length: 6 }, (_, i) => ({ - id: i, - size: 2 + Math.random() * 3, - x: 10 + Math.random() * 80, - y: 10 + Math.random() * 80, - duration: 15 + Math.random() * 20, - delay: Math.random() * -20, -})); +const floatingParticles = getFloatingParticles(); export function Hero() { const { t } = useTranslation(); diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx index b6e8d27..479542c 100644 --- a/apps/web/src/components/theme-toggle.tsx +++ b/apps/web/src/components/theme-toggle.tsx @@ -1,10 +1,11 @@ -"use client"; - -import { Button } from "./ui/button"; -import { useTheme } from "next-themes"; -import { cn } from "@/utils/ui"; -import { Sun03Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; +"use client"; + +import { Button } from "./ui/button"; +import { useTheme } from "next-themes"; +import { cn } from "@/utils/ui"; +import { Sun03Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useEffect, useState } from "react"; interface ThemeToggleProps { className?: string; @@ -12,25 +13,34 @@ interface ThemeToggleProps { onToggle?: (e: React.MouseEvent) => void; } -export function ThemeToggle({ - className, - iconClassName, - onToggle, -}: ThemeToggleProps) { - const { theme, setTheme } = useTheme(); - - return ( - - ); -} +export function ThemeToggle({ + className, + iconClassName, + onToggle, +}: ThemeToggleProps) { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const currentTheme = mounted ? resolvedTheme : undefined; + + return ( + + ); +} diff --git a/apps/web/src/lib/__tests__/navigation-utils.test.ts b/apps/web/src/lib/__tests__/navigation-utils.test.ts new file mode 100644 index 0000000..642b83e --- /dev/null +++ b/apps/web/src/lib/__tests__/navigation-utils.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { isExternalHref } from "../navigation-utils"; + +describe("isExternalHref", () => { + test("treats absolute http and https urls as external", () => { + expect(isExternalHref("https://github.com/msgbyte/cutia")).toBe(true); + expect(isExternalHref("http://example.com/docs")).toBe(true); + }); + + test("keeps local paths and hash links internal", () => { + expect(isExternalHref("/projects")).toBe(false); + expect(isExternalHref("#features")).toBe(false); + expect(isExternalHref("mailto:hello@example.com")).toBe(false); + }); +}); diff --git a/apps/web/src/lib/ai/agent/__tests__/llm-client.test.ts b/apps/web/src/lib/ai/agent/__tests__/llm-client.test.ts new file mode 100644 index 0000000..41f0d6d --- /dev/null +++ b/apps/web/src/lib/ai/agent/__tests__/llm-client.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { getChatCompletionsUrl } from "../llm-client"; + +describe("getChatCompletionsUrl", () => { + test("uses same-origin proxy for external base urls", () => { + expect( + getChatCompletionsUrl({ + baseUrl: "http://120.27.203.19:18080/v1", + }), + ).toBe("/api/ai/agent/chat?baseUrl=http%3A%2F%2F120.27.203.19%3A18080%2Fv1"); + }); + + test("keeps relative base urls on same origin", () => { + expect(getChatCompletionsUrl({ baseUrl: "/api/openai" })).toBe( + "/api/openai/chat/completions", + ); + }); + + test("falls back to the default OpenAI base url through the proxy", () => { + expect(getChatCompletionsUrl({ baseUrl: "" })).toBe( + "/api/ai/agent/chat?baseUrl=https%3A%2F%2Fapi.openai.com%2Fv1", + ); + }); +}); diff --git a/apps/web/src/lib/ai/agent/llm-client.ts b/apps/web/src/lib/ai/agent/llm-client.ts index 7b012a8..d0a8828 100644 --- a/apps/web/src/lib/ai/agent/llm-client.ts +++ b/apps/web/src/lib/ai/agent/llm-client.ts @@ -26,6 +26,31 @@ export interface ChatCompletionResult { }>; } +function normalizeBaseUrl(baseUrl: string) { + return (baseUrl || "https://api.openai.com/v1").replace(/\/+$/, ""); +} + +function isRelativeBaseUrl(baseUrl: string) { + return baseUrl.startsWith("/"); +} + +export function getChatCompletionsUrl({ + baseUrl, +}: { + baseUrl: string; +}) { + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + + if (isRelativeBaseUrl(normalizedBaseUrl)) { + return `${normalizedBaseUrl}/chat/completions`; + } + + const params = new URLSearchParams({ + baseUrl: normalizedBaseUrl, + }); + return `/api/ai/agent/chat?${params.toString()}`; +} + export async function streamChatCompletion({ config, messages, @@ -39,11 +64,7 @@ export async function streamChatCompletion({ callbacks: StreamCallbacks; signal?: AbortSignal; }): Promise { - const baseUrl = (config.baseUrl || "https://api.openai.com/v1").replace( - /\/+$/, - "", - ); - const url = `${baseUrl}/chat/completions`; + const url = getChatCompletionsUrl({ baseUrl: config.baseUrl }); const body: Record = { model: config.model || "gpt-4.1", diff --git a/apps/web/src/lib/navigation-utils.ts b/apps/web/src/lib/navigation-utils.ts new file mode 100644 index 0000000..9324959 --- /dev/null +++ b/apps/web/src/lib/navigation-utils.ts @@ -0,0 +1,3 @@ +export function isExternalHref(href: string) { + return /^https?:\/\//.test(href); +} diff --git a/apps/web/src/lib/navigation.ts b/apps/web/src/lib/navigation.ts deleted file mode 100644 index 772ab1f..0000000 --- a/apps/web/src/lib/navigation.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createNavigation } from "@i18next-toolkit/nextjs-approuter/navigation"; -import { i18nConfig } from "../i18n.config"; - -export const { Link, redirect, usePathname, useRouter } = - createNavigation(i18nConfig); diff --git a/apps/web/src/lib/navigation.tsx b/apps/web/src/lib/navigation.tsx new file mode 100644 index 0000000..e5499e8 --- /dev/null +++ b/apps/web/src/lib/navigation.tsx @@ -0,0 +1,27 @@ +import { createNavigation } from "@i18next-toolkit/nextjs-approuter/navigation"; +import { + forwardRef, + type ComponentPropsWithoutRef, +} from "react"; +import { i18nConfig } from "../i18n.config"; +import { isExternalHref } from "./navigation-utils"; + +const navigation = createNavigation(i18nConfig); +const BaseLink = navigation.Link; + +type BaseLinkProps = ComponentPropsWithoutRef; +type LinkProps = BaseLinkProps & ComponentPropsWithoutRef<"a">; + +export const Link = forwardRef( + ({ href, ...props }, ref) => { + if (typeof href === "string" && isExternalHref(href)) { + return ; + } + + return ; + }, +); + +Link.displayName = "Link"; + +export const { redirect, usePathname, useRouter } = navigation;