mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Compare commits
6 Commits
dev
...
feat/chang
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19b86fe80f | ||
|
|
9ec8419cc8 | ||
|
|
6f4fc1af22 | ||
|
|
7ff71050db | ||
|
|
ee6a22bfd8 | ||
|
|
1750c833ee |
@@ -7,6 +7,7 @@ import "./globals.css";
|
||||
import { Providers } from "@/app/providers";
|
||||
import { CookieConsentBanner } from "@/components/molecules/CookieConsentBanner/CookieConsentBanner";
|
||||
import { ErrorBoundary } from "@/components/molecules/ErrorBoundary/ErrorBoundary";
|
||||
import { ChangelogPopup } from "@/components/molecules/ChangelogPopup/ChangelogPopup";
|
||||
import TallyPopupSimple from "@/components/molecules/TallyPoup/TallyPopup";
|
||||
import { Toaster } from "@/components/molecules/Toast/toaster";
|
||||
import { SetupAnalytics } from "@/services/analytics";
|
||||
@@ -65,6 +66,7 @@ export default async function RootLayout({
|
||||
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch">
|
||||
{children}
|
||||
<TallyPopupSimple />
|
||||
<ChangelogPopup />
|
||||
<VercelAnalyticsWrapper />
|
||||
|
||||
{/* React Query DevTools is only available in development */}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ArrowRight, ArrowSquareOut, Sparkle, X } from "@phosphor-icons/react";
|
||||
import { ChangelogModal } from "./components/ChangelogModal";
|
||||
import { useChangelog } from "./useChangelog";
|
||||
|
||||
export function ChangelogPopup() {
|
||||
const {
|
||||
isVisible,
|
||||
latestEntry,
|
||||
allEntries,
|
||||
isFading,
|
||||
dismiss,
|
||||
pauseAutoDismiss,
|
||||
resumeAutoDismiss,
|
||||
showFullChangelog,
|
||||
openFullChangelog,
|
||||
closeFullChangelog,
|
||||
selectedEntry,
|
||||
selectEntry,
|
||||
entryMarkdown,
|
||||
isLoadingMarkdown,
|
||||
} = useChangelog();
|
||||
|
||||
if (showFullChangelog) {
|
||||
return (
|
||||
<ChangelogModal
|
||||
entries={allEntries}
|
||||
selectedEntry={selectedEntry}
|
||||
entryMarkdown={entryMarkdown}
|
||||
isLoadingMarkdown={isLoadingMarkdown}
|
||||
onSelectEntry={selectEntry}
|
||||
onClose={closeFullChangelog}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isVisible || !latestEntry) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 w-[400px] max-w-[calc(100vw-2rem)] transition-all duration-500 ease-out ${
|
||||
isFading
|
||||
? "pointer-events-none translate-y-2 opacity-0"
|
||||
: "translate-y-0 opacity-100"
|
||||
}`}
|
||||
onMouseEnter={pauseAutoDismiss}
|
||||
onMouseLeave={resumeAutoDismiss}
|
||||
role="dialog"
|
||||
aria-label="What's new"
|
||||
>
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-background shadow-2xl shadow-black/10">
|
||||
<div className="bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 px-5 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkle className="h-4 w-4 text-white/90" weight="fill" />
|
||||
<Text
|
||||
variant="body-medium"
|
||||
as="span"
|
||||
className="text-sm font-semibold text-white"
|
||||
>
|
||||
What's New
|
||||
</Text>
|
||||
</div>
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="rounded-md p-0.5 text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Dismiss changelog"
|
||||
>
|
||||
<X className="h-4 w-4" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4">
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="text-[15px] font-semibold leading-snug text-foreground"
|
||||
>
|
||||
{latestEntry.highlights}
|
||||
</Text>
|
||||
<Text variant="body" className="mt-1 text-xs text-muted-foreground">
|
||||
{latestEntry.dateRange}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-border bg-secondary/50 px-5 py-2.5">
|
||||
<button
|
||||
onClick={() => openFullChangelog(latestEntry)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Read more
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
<a
|
||||
href={latestEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs font-medium text-accent transition-colors hover:text-accent/80"
|
||||
>
|
||||
Open in docs
|
||||
<ArrowSquareOut className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const CHANGELOG_BASE_URL =
|
||||
"https://agpt.co/docs/platform/changelog/changelog";
|
||||
export const CHANGELOG_INDEX_MD_URL = `${CHANGELOG_BASE_URL}.md`;
|
||||
export const STORAGE_KEY = "autogpt-changelog-last-seen";
|
||||
export const AUTO_DISMISS_MS = 8000;
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
export function ChangelogMarkdownContent({ markdown }: { markdown: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className="prose prose-sm max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-accent prose-a:no-underline hover:prose-a:underline prose-strong:text-foreground prose-img:rounded-lg prose-img:shadow-md"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children, href, ...props }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ""}
|
||||
className="my-4 h-auto max-w-full rounded-lg shadow-md"
|
||||
loading="lazy"
|
||||
{...(props as React.ImgHTMLAttributes<HTMLImageElement>)}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ArrowSquareOut, Sparkle, X } from "@phosphor-icons/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { CHANGELOG_BASE_URL } from "../changelog-constants";
|
||||
import { ChangelogEntry } from "../useChangelog";
|
||||
|
||||
const ChangelogMarkdownContent = dynamic(
|
||||
() =>
|
||||
import("./ChangelogMarkdownContent").then(
|
||||
(mod) => mod.ChangelogMarkdownContent,
|
||||
),
|
||||
{
|
||||
loading: () => (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
Loading…
|
||||
</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
interface ChangelogModalProps {
|
||||
entries: ChangelogEntry[];
|
||||
selectedEntry: ChangelogEntry | null;
|
||||
entryMarkdown: string | null;
|
||||
isLoadingMarkdown: boolean;
|
||||
onSelectEntry: (entry: ChangelogEntry) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangelogModal({
|
||||
entries,
|
||||
selectedEntry,
|
||||
entryMarkdown,
|
||||
isLoadingMarkdown,
|
||||
onSelectEntry,
|
||||
onClose,
|
||||
}: ChangelogModalProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-4 z-50 flex overflow-hidden rounded-2xl border border-border bg-background shadow-2xl sm:inset-6 md:inset-10">
|
||||
<div className="hidden w-72 shrink-0 flex-col border-r border-border bg-secondary md:flex">
|
||||
<div className="flex items-center gap-2 bg-gradient-to-r from-violet-600 via-purple-600 to-indigo-600 px-4 py-3">
|
||||
<Sparkle className="h-4 w-4 text-white" weight="fill" />
|
||||
<Text
|
||||
variant="body-medium"
|
||||
as="span"
|
||||
className="text-sm font-bold text-white"
|
||||
>
|
||||
Changelog
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-2">
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.slug}
|
||||
onClick={() => onSelectEntry(entry)}
|
||||
className={`mb-1 block w-full rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||||
selectedEntry?.slug === entry.slug
|
||||
? "bg-accent/10 ring-1 ring-accent/20"
|
||||
: "hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="text-[13px] font-medium leading-snug text-foreground"
|
||||
>
|
||||
{entry.highlights}
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
className="mt-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{entry.dateRange}
|
||||
</Text>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border p-3">
|
||||
<a
|
||||
href={CHANGELOG_BASE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 rounded-full bg-primary px-3 py-2 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
View on Docs
|
||||
<ArrowSquareOut className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border bg-background px-4 py-2">
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<Sparkle className="h-4 w-4 text-accent" weight="fill" />
|
||||
{entries.length > 0 && (
|
||||
<select
|
||||
className="max-w-[200px] truncate rounded-md border border-border bg-background px-2 py-1 text-sm text-foreground"
|
||||
value={selectedEntry?.slug || ""}
|
||||
onChange={(e) => {
|
||||
const entry = entries.find((en) => en.slug === e.target.value);
|
||||
if (entry) onSelectEntry(entry);
|
||||
}}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<option key={entry.slug} value={entry.slug}>
|
||||
{entry.dateRange}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{selectedEntry && (
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground md:flex"
|
||||
>
|
||||
Open in docs
|
||||
<ArrowSquareOut className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
aria-label="Close changelog"
|
||||
>
|
||||
<X className="h-5 w-5" weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6 md:px-10">
|
||||
{isLoadingMarkdown && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-accent" />
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
{entryMarkdown && (
|
||||
<ChangelogMarkdownContent markdown={entryMarkdown} />
|
||||
)}
|
||||
{!isLoadingMarkdown && !entryMarkdown && selectedEntry && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Could not load changelog entry.{" "}
|
||||
<a
|
||||
href={selectedEntry.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline"
|
||||
>
|
||||
View on docs
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AUTO_DISMISS_MS,
|
||||
CHANGELOG_BASE_URL,
|
||||
CHANGELOG_INDEX_MD_URL,
|
||||
STORAGE_KEY,
|
||||
} from "./changelog-constants";
|
||||
|
||||
export interface ChangelogEntry {
|
||||
slug: string;
|
||||
dateRange: string;
|
||||
highlights: string;
|
||||
url: string;
|
||||
mdUrl: string;
|
||||
}
|
||||
|
||||
function parseChangelogIndex(md: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
const rowPattern =
|
||||
/\|\s*\[([^\]]+)\]\((https?:\/\/[^)]+\/changelog\/changelog\/([a-z0-9-]+))\)\s*\|\s*([^|]+)\|/g;
|
||||
|
||||
let match;
|
||||
while ((match = rowPattern.exec(md)) !== null) {
|
||||
const [, dateRange, url, slug, highlights] = match;
|
||||
entries.push({
|
||||
slug,
|
||||
dateRange: dateRange.trim(),
|
||||
highlights: highlights.trim(),
|
||||
url,
|
||||
mdUrl: `${CHANGELOG_BASE_URL}/${slug}.md`,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function useChangelog() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isFading, setIsFading] = useState(false);
|
||||
const [showFullChangelog, setShowFullChangelog] = useState(false);
|
||||
const [latestEntry, setLatestEntry] = useState<ChangelogEntry | null>(null);
|
||||
const [allEntries, setAllEntries] = useState<ChangelogEntry[]>([]);
|
||||
const [selectedEntry, setSelectedEntry] = useState<ChangelogEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [entryMarkdown, setEntryMarkdown] = useState<string | null>(null);
|
||||
const [isLoadingMarkdown, setIsLoadingMarkdown] = useState(false);
|
||||
|
||||
const autoDismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isPaused = useRef(false);
|
||||
const isDismissing = useRef(false);
|
||||
const mdAbort = useRef<AbortController | null>(null);
|
||||
|
||||
function clearTimers() {
|
||||
if (autoDismissTimer.current) clearTimeout(autoDismissTimer.current);
|
||||
if (fadeTimer.current) clearTimeout(fadeTimer.current);
|
||||
autoDismissTimer.current = null;
|
||||
fadeTimer.current = null;
|
||||
}
|
||||
|
||||
function markAsSeen(slug: string) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, slug);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (isDismissing.current) return;
|
||||
isDismissing.current = true;
|
||||
clearTimers();
|
||||
setIsFading(true);
|
||||
fadeTimer.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setIsFading(false);
|
||||
isDismissing.current = false;
|
||||
if (latestEntry) markAsSeen(latestEntry.slug);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function startAutoDismiss() {
|
||||
if (isPaused.current || showFullChangelog) return;
|
||||
clearTimers();
|
||||
autoDismissTimer.current = setTimeout(() => {
|
||||
if (!isPaused.current && !showFullChangelog) dismiss();
|
||||
}, AUTO_DISMISS_MS);
|
||||
}
|
||||
|
||||
function pauseAutoDismiss() {
|
||||
isPaused.current = true;
|
||||
if (autoDismissTimer.current) {
|
||||
clearTimeout(autoDismissTimer.current);
|
||||
autoDismissTimer.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function resumeAutoDismiss() {
|
||||
if (isDismissing.current) return;
|
||||
isPaused.current = false;
|
||||
startAutoDismiss();
|
||||
}
|
||||
|
||||
function fetchEntryMarkdown(entry: ChangelogEntry) {
|
||||
mdAbort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
mdAbort.current = controller;
|
||||
|
||||
setIsLoadingMarkdown(true);
|
||||
setEntryMarkdown(null);
|
||||
|
||||
fetch(entry.mdUrl, { signal: controller.signal })
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.then((md) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const cleaned = md
|
||||
.replace(/\{%.*?%\}/gs, "")
|
||||
.replace(/<figure>|<\/figure>/g, "")
|
||||
.replace(/<figcaption>.*?<\/figcaption>/gs, "")
|
||||
.replace(/<details>/g, "\n---\n")
|
||||
.replace(/<\/details>/g, "")
|
||||
.replace(/<summary>(.*?)<\/summary>/g, "### $1");
|
||||
setEntryMarkdown(cleaned);
|
||||
})
|
||||
.catch(() => {
|
||||
/* abort or network error — non-critical */
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setIsLoadingMarkdown(false);
|
||||
});
|
||||
}
|
||||
|
||||
function openFullChangelog(entry?: ChangelogEntry) {
|
||||
clearTimers();
|
||||
isPaused.current = true;
|
||||
setIsVisible(false);
|
||||
setIsFading(false);
|
||||
isDismissing.current = false;
|
||||
const target = entry || latestEntry;
|
||||
if (target) {
|
||||
setSelectedEntry(target);
|
||||
fetchEntryMarkdown(target);
|
||||
markAsSeen(target.slug);
|
||||
}
|
||||
setShowFullChangelog(true);
|
||||
}
|
||||
|
||||
function closeFullChangelog() {
|
||||
mdAbort.current?.abort();
|
||||
setShowFullChangelog(false);
|
||||
setEntryMarkdown(null);
|
||||
setSelectedEntry(null);
|
||||
}
|
||||
|
||||
function selectEntry(entry: ChangelogEntry) {
|
||||
setSelectedEntry(entry);
|
||||
fetchEntryMarkdown(entry);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch(CHANGELOG_INDEX_MD_URL)
|
||||
.then((res) => (res.ok ? res.text() : ""))
|
||||
.then((md) => {
|
||||
if (cancelled || !md) return;
|
||||
|
||||
const entries = parseChangelogIndex(md);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
setAllEntries(entries);
|
||||
setLatestEntry(entries[0]);
|
||||
|
||||
try {
|
||||
const lastSeen = localStorage.getItem(STORAGE_KEY);
|
||||
if (lastSeen === entries[0].slug) return;
|
||||
} catch {
|
||||
/* show anyway */
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!cancelled) setIsVisible(true);
|
||||
}, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
/* non-critical */
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimers();
|
||||
mdAbort.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && !isFading && !showFullChangelog) startAutoDismiss();
|
||||
}, [isVisible, isFading, showFullChangelog]);
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
isFading,
|
||||
latestEntry,
|
||||
allEntries,
|
||||
entryMarkdown,
|
||||
isLoadingMarkdown,
|
||||
dismiss,
|
||||
pauseAutoDismiss,
|
||||
resumeAutoDismiss,
|
||||
showFullChangelog,
|
||||
openFullChangelog,
|
||||
closeFullChangelog,
|
||||
selectedEntry,
|
||||
selectEntry,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user