Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"dev:app": "VITE_ENABLE_APP_ROUTES_IN_BROWSER=true vite",
"build:web": "tsc && vite build",
"build": "bun run build:web",
"preview": "vite preview",
Expand Down
3 changes: 2 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export default defineConfig({
},
],
webServer: {
command: "npm run dev -- --host 127.0.0.1 --port 1420",
command:
"VITE_ENABLE_APP_ROUTES_IN_BROWSER=true npm run dev -- --host 127.0.0.1 --port 1420",
url: "http://127.0.0.1:1420",
reuseExistingServer: !process.env.CI,
timeout: 120000,
Expand Down
14 changes: 10 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { getPengineHealth } from "./modules/bot/api";
import { useAppSessionStore } from "./modules/bot/store/appSessionStore";
import { isMarketingWebsite } from "./shared/runtimeTarget";

const LandingPage = lazy(() =>
import("./pages/LandingPage").then((m) => ({ default: m.LandingPage })),
Expand Down Expand Up @@ -70,6 +71,7 @@ function StartupDashboardRedirect() {

function App() {
const [sessionReady, setSessionReady] = useState(false);
const marketingSite = isMarketingWebsite();

useEffect(() => {
if (useAppSessionStore.persist.hasHydrated()) {
Expand All @@ -94,13 +96,17 @@ function App() {

return (
<div data-testid="app-ready">
<StartupDashboardRedirect />
{!marketingSite && <StartupDashboardRedirect />}
<Suspense fallback={<RoutePageFallback />}>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/setup" element={<SetupPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
{!marketingSite && (
<>
<Route path="/setup" element={<SetupPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
</>
)}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
Expand Down
45 changes: 37 additions & 8 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Link } from "react-router-dom";
import { TerminalPreview } from "../modules/bot/components/TerminalPreview";
import { DownloadLatestButton } from "../modules/updater";
import { AboutLegalContent } from "../shared/about/AboutLegalContent";
import { isMarketingWebsite } from "../shared/runtimeTarget";
import { PhoneMockup } from "../shared/ui/PhoneMockup";
import { SpecMockup } from "../shared/ui/SpecMockup";
import { TopMenu } from "../shared/ui/TopMenu";
Expand Down Expand Up @@ -106,6 +108,8 @@ const specCards = [
];

export function LandingPage() {
const marketingSite = isMarketingWebsite();

return (
<div className="relative overflow-x-clip pb-20">
<TopMenu />
Expand All @@ -128,10 +132,14 @@ export function LandingPage() {
tools become new abilities on demand.
</p>
<div className="mt-5 flex flex-wrap gap-3">
<Link to="/setup" className="primary-button px-6">
Scan and connect
</Link>
<DownloadLatestButton className="secondary-button px-6" />
{marketingSite ? (
<DownloadLatestButton className="primary-button px-6" />
) : (
<Link to="/setup" className="primary-button px-6">
Scan and connect
</Link>
)}
{!marketingSite && <DownloadLatestButton className="secondary-button px-6" />}
<a href="#spec" className="secondary-button px-6">
Read the spec
</a>
Expand Down Expand Up @@ -438,10 +446,14 @@ export function LandingPage() {
Your AI. Your machine. Your rules.
</h2>
<div className="mt-8 flex flex-wrap justify-center gap-4">
<Link to="/setup" className="primary-button px-6">
Open setup wizard
</Link>
<DownloadLatestButton className="secondary-button px-6" />
{marketingSite ? (
<DownloadLatestButton className="primary-button px-6" />
) : (
<Link to="/setup" className="primary-button px-6">
Open setup wizard
</Link>
)}
{!marketingSite && <DownloadLatestButton className="secondary-button px-6" />}
<a
href="https://github.com/pengine-ai/pengine"
className="secondary-button gap-2 px-6"
Expand All @@ -462,6 +474,23 @@ export function LandingPage() {
</div>
</div>
</section>

{marketingSite && (
<section id="about" className="scroll-mt-28 pt-24">
<p className="mono-label">About</p>
<div className="mt-3 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<h2 className="max-w-3xl text-4xl font-extrabold tracking-tight text-white sm:text-5xl">
Contact, privacy, and project
</h2>
<p className="max-w-xl subtle-copy">
Links and compliance information for the open source app.
</p>
</div>
<div className="mt-10 panel p-6 sm:p-8">
<AboutLegalContent />
</div>
</section>
)}
</main>
</div>
);
Expand Down
128 changes: 2 additions & 126 deletions src/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@ import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { getPengineHealth } from "../modules/bot/api";
import { useAppSessionStore } from "../modules/bot/store/appSessionStore";
import { AboutLegalContent } from "../shared/about/AboutLegalContent";
import { TopMenu } from "../shared/ui/TopMenu";

type SettingsTab = "preferences" | "about";

const CONTACT = {
x: { label: "X (Twitter)", href: "https://x.com/PengineAI" },
github: { label: "GitHub", href: "https://github.com/pengine-ai/pengine" },
} as const;

export function SettingsPage() {
const isDeviceConnected = useAppSessionStore((s) => s.isDeviceConnected);
const botUsername = useAppSessionStore((s) => s.botUsername);
Expand Down Expand Up @@ -128,127 +124,7 @@ export function SettingsPage() {

{tab === "about" && (
<div className="grid gap-10" role="tabpanel" aria-labelledby="settings-tab-about">
<section data-testid="settings-about-contact">
<p className="mono-label">Contact info</p>
<p className="mt-3 subtle-copy">
Project links and maintainer contact on social platforms.
</p>
<ul className="mt-4 grid gap-2 font-mono text-sm">
<li>
<a
href={CONTACT.x.href}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-200/90 underline decoration-cyan-200/30 underline-offset-2 transition hover:decoration-cyan-200/80"
>
{CONTACT.x.label}
</a>
</li>
<li>
<a
href={CONTACT.github.href}
target="_blank"
rel="noopener noreferrer"
className="text-cyan-200/90 underline decoration-cyan-200/30 underline-offset-2 transition hover:decoration-cyan-200/80"
>
{CONTACT.github.label}
</a>
</li>
</ul>
</section>

<section data-testid="settings-about-privacy">
<p className="mono-label">Privacy info</p>
<div className="mt-4 space-y-6 subtle-copy">
<div>
<h2 className="font-mono text-sm font-semibold uppercase tracking-[0.12em] text-slate-200">
Privacy Policy
</h2>
<p className="mt-2">
Pengine is a{" "}
<strong className="font-semibold text-slate-200">desktop app</strong> that
runs an agent loop on your machine. There is no Pengine-hosted account or
cloud database for your chats: ordinary use keeps configuration and skills on
your device, while the bot and tools you enable determine what leaves it.
</p>
</div>
<div>
<h3 className="font-mono text-xs font-semibold uppercase tracking-[0.14em] text-slate-300">
Analytics and telemetry
</h3>
<p className="mt-2">
The app does not include first-party analytics or behavioral tracking, and it
does not phone home to a Pengine telemetry service. The dashboard may{" "}
<strong className="font-semibold text-slate-200">
check GitHub’s public API
</strong>{" "}
for new releases (version metadata only), which is subject to GitHub’s own
policies and your network path.
</p>
</div>
<div>
<h3 className="font-mono text-xs font-semibold uppercase tracking-[0.14em] text-slate-300">
What stays on your device
</h3>
<p className="mt-2">
Pengine stores app data under your OS app-data location: connection metadata,
skills, MCP configuration, and UI-related settings files. Sensitive values
such as your{" "}
<strong className="font-semibold text-slate-200">Telegram bot token</strong>{" "}
and MCP secrets you configure are kept in the{" "}
<strong className="font-semibold text-slate-200">
platform secure store
</strong>{" "}
(for example Keychain on macOS), not in a Pengine cloud.
</p>
</div>
<div>
<h3 className="font-mono text-xs font-semibold uppercase tracking-[0.14em] text-slate-300">
Network, Telegram, and inference
</h3>
<p className="mt-2">
When your bot is connected, message traffic uses{" "}
<strong className="font-semibold text-slate-200">Telegram’s services</strong>{" "}
per Telegram’s terms. The app sends prompts and tool activity to{" "}
<strong className="font-semibold text-slate-200">
Ollama (or another endpoint you configure)
</strong>
— typically on your LAN or localhost, but cloud or remote models are possible
if you point the stack there. Any{" "}
<strong className="font-semibold text-slate-200">
MCP servers, containers, or custom tools
</strong>{" "}
you add can read or forward data according to their own configuration; treat
them like any other software you install.
</p>
</div>
<div>
<h3 className="font-mono text-xs font-semibold uppercase tracking-[0.14em] text-slate-300">
Responsibility
</h3>
<p className="mt-2">
You choose the bot, models, skills, and tools. This policy describes how
Pengine is designed to run locally; it does not replace Telegram’s, your host
OS’s, or your inference provider’s policies. For exact storage and startup
behavior, see the project documentation in the repository.
</p>
</div>
</div>
</section>

<section data-testid="settings-about-app">
<p className="mono-label">About the app</p>
<p className="mt-3 subtle-copy">
Pengine is a local AI agent runtime: it connects your Telegram bot to Ollama (or
compatible inference) so conversations and tools run on your hardware. The project
is open source; see the GitHub repository above for license and source code.
</p>
{appVersion && (
<p className="mt-4 font-mono text-[11px] text-white/45">
Reported app version: v{appVersion}
</p>
)}
</section>
<AboutLegalContent appVersion={appVersion} />
</div>
)}
</div>
Expand Down
Loading